hyperclient 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/RELEASING.md CHANGED
@@ -1,69 +1,75 @@
1
- Releasing Hyperclient
2
- =====================
1
+ # Releasing Hyperclient
3
2
 
4
- There're no particular rules about when to release hyperclient. Release bug fixes frequenty, features not so frequently and breaking API changes rarely.
3
+ Release bug fixes frequently, features less frequently, and breaking API changes rarely.
5
4
 
6
- ### Release
5
+ ## Prepare the Release
7
6
 
8
- Run tests, check that all tests succeed locally.
7
+ Start with a clean, up-to-date `master` branch. Check that CI has succeeded for all supported platforms.
9
8
 
10
- ```
11
- bundle install
12
- rake
13
- ```
14
-
15
- Check that the last build succeeded in CI for all supported platforms.
16
-
17
- Increment the version, modify [lib/hyperclient/version.rb](lib/hyperclient/version.rb).
9
+ Set the release version in [lib/hyperclient/version.rb](lib/hyperclient/version.rb). Use a patch version for backward-compatible fixes, a minor version for backward-compatible features, and a major version for breaking changes.
18
10
 
19
- * Increment the third number if the release has bug fixes and/or very minor features, only (eg. change `0.5.1` to `0.5.2`).
20
- * Increment the second number if the release contains major features or breaking API changes (eg. change `0.5.1` to `0.4.0`).
11
+ Replace the next-version heading in [CHANGELOG.md](CHANGELOG.md) with the release version and date, for example `### 2.1.0 (2026/10/14)`. Remove the "Your contribution here." placeholder from that release.
21
12
 
22
- Change "Next Release" in [CHANGELOG.md](CHANGELOG.md) to the new version.
13
+ Run the full suite, then commit and push the release preparation:
23
14
 
24
- ```
25
- 0.4.0 (2014-01-27)
26
- ==================
15
+ ```sh
16
+ bundle install
17
+ bundle exec rake
18
+ git add CHANGELOG.md lib/hyperclient/version.rb
19
+ git commit -m "Preparing for release, 2.1.0."
20
+ git push origin master
27
21
  ```
28
22
 
29
- Remove the line with "Your contribution here.", since there will be no more contributions to this release.
23
+ Wait for CI on the release preparation commit to succeed before publishing.
30
24
 
31
- Commit your changes.
25
+ ## Publish to RubyGems and GitHub
32
26
 
27
+ Publish the gem using Bundler's release task. This builds the gem, creates and pushes the version tag, and publishes to RubyGems. Complete RubyGems MFA when prompted.
28
+
29
+ ```sh
30
+ bundle exec rake release
33
31
  ```
34
- git add CHANGELOG.md lib/hyperclient/version.rb
35
- git commit -m "Preparing for release, 0.4.0."
36
- git push origin master
32
+
33
+ Creating a Git tag does **not** create a GitHub release. After confirming that the gem was published successfully, create a GitHub release for the same tag. Put the matching changelog section's entries in a temporary file and use them as the release notes:
34
+
35
+ ```sh
36
+ version=2.1.0
37
+ notes="$(mktemp)"
38
+ awk -v version="$version" '
39
+ /^### / {
40
+ if (found) exit
41
+ if ($2 == version) found = 1
42
+ next
43
+ }
44
+ found { print }
45
+ ' CHANGELOG.md > "$notes"
46
+ gh release create "v$version" --repo codegram/hyperclient --verify-tag --title "$version" --notes-file "$notes" --latest
47
+ rm "$notes"
37
48
  ```
38
49
 
39
- Release.
50
+ Verify both publications before considering the release complete:
40
51
 
52
+ ```sh
53
+ curl --fail --silent --show-error "https://rubygems.org/api/v2/rubygems/hyperclient/versions/$version.json"
54
+ gh release view "v$version" --repo codegram/hyperclient
41
55
  ```
42
- $ rake release
43
56
 
44
- hyperclient 0.4.0 built to pkg/hyperclient-0.4.0.gem.
45
- Tagged v0.4.0.
46
- Pushed git commits and tags.
47
- Pushed hyperclient 0.4.0 to rubygems.org.
48
- ```
57
+ If publishing is interrupted, inspect the existing tag, RubyGems version, and GitHub release first. Resume only the missing step; do not move or recreate a published tag.
49
58
 
50
- ### Prepare for the Next Version
59
+ ## Prepare the Next Version
51
60
 
52
- Add the next release to [CHANGELOG.md](CHANGELOG.md).
61
+ Increment the development patch version in [lib/hyperclient/version.rb](lib/hyperclient/version.rb), for example from `2.1.0` to `2.1.1`. Add the matching unreleased section at the top of [CHANGELOG.md](CHANGELOG.md):
53
62
 
54
- ```
55
- Next Release
56
- ============
63
+ ```markdown
64
+ ### 2.1.1 (Next)
57
65
 
58
66
  * Your contribution here.
59
67
  ```
60
68
 
61
- Increment the minor version, modify [lib/hyperclient/version.rb](lib/hyperclient/version.rb).
69
+ Commit and push:
62
70
 
63
- Comit your changes.
64
-
65
- ```
71
+ ```sh
66
72
  git add CHANGELOG.md lib/hyperclient/version.rb
67
- git commit -m "Preparing for next release, 0.4.1."
73
+ git commit -m "Preparing for next release, 2.1.1."
68
74
  git push origin master
69
75
  ```
@@ -0,0 +1,42 @@
1
+ # Local Oxford HAL Demo
2
+
3
+ This read-only demo uses a small selection of public place facts from Oxford's retired mobile API. It runs entirely on localhost; neither Oxford nor the Internet Archive is contacted when running the example. The information is historical, not a current directory.
4
+
5
+ From the repository root, install dependencies and start the server:
6
+
7
+ ```sh
8
+ bundle install
9
+ bundle exec ruby examples/oxford/server.rb
10
+ ```
11
+
12
+ In a second terminal:
13
+
14
+ ```sh
15
+ bundle exec ruby -Ilib examples/oxford/client.rb
16
+ ```
17
+
18
+ The server binds only to `127.0.0.1:9292`. Set `PORT` on the server and `OXFORD_URL` on the client to use another port. Stop the server with Ctrl-C.
19
+
20
+ The client demonstrates collection traversal, attributes, a templated place link, following `primary_place`, embedded file metadata, and CURIE expansion. It prints image URLs without downloading images. Unknown paths return 404; writes return 405.
21
+
22
+ ## Provenance and Adaptations
23
+
24
+ The factual fields and original place path in `places.json` were transcribed from [Balliol College's archived response, September 10, 2020](https://web.archive.org/web/20200910141343id_/https://api.m.ox.ac.uk/places/oxpoints:23232339). The record retains the original ID, name, address, coordinates, self/primary-place paths, and one image URL. No image or descriptive prose is redistributed.
25
+
26
+ The [archived API root, July 12, 2024](https://web.archive.org/web/20240712050404id_/http://api.m.ox.ac.uk/) demonstrates the original HAL-style links. See also [Moxie's OxPoints importer documentation](https://moxie.readthedocs.io/en/latest/places/oxpoints.html).
27
+
28
+ ## Data License
29
+
30
+ The underlying OxPoints place data is dedicated to the public domain under [CC0](https://creativecommons.org/publicdomain/zero/1.0/). Oxford's own presentation, [*OxPoints and data.ox.ac.uk*, Alexander Dutton, InfoDev, OUCS (2011)](https://old.ictf.ox.ac.uk/conference/2011/presentations/wks11_oxpoints_dataox.pdf), identifies names, coordinates, addresses, and identifiers as OxPoints data (slides 2-5) and explicitly states "Public domain (CC0; no attribution required)" (slide 8). The place facts in `places.json` come from this dataset via the archived mobile API response cited above; the HAL wrappers and local links are demo adaptations.
31
+
32
+ This is not a license claim for full archived API responses or photographs. The depiction URL is retained only as a reference to an external image; the image itself is not included, fetched, or covered by this notice. Newly authored server code and documentation are under this project's license. Moxie's Apache-2.0 code license is separate from the OxPoints data license.
33
+
34
+ The root, collection wrapper, templated `place` link, `oxford` CURIE, and local relation documentation are demo additions, not a reconstruction of the original service. Responses use `application/hal+json`; the archived service returned `application/json`. Relative place links retain their original paths and resolve locally. Unavailable child links, images, and unrelated API endpoints are not mirrored.
35
+
36
+ ## Tests
37
+
38
+ ```sh
39
+ bundle exec ruby -Itest test/oxford_example_test.rb
40
+ ```
41
+
42
+ The tests exercise the same app and client using local HTTP stubs, without requiring a running server or external network access. They are also included in `bundle exec rake test`.
@@ -0,0 +1,48 @@
1
+ require 'json'
2
+
3
+ module OxfordExample
4
+ # A read-only HAL demo, independent of Oxford's retired API.
5
+ class App
6
+ def initialize
7
+ @places = JSON.parse(File.read(File.join(__dir__, 'places.json')))
8
+ end
9
+
10
+ def call(env)
11
+ return response(405, { 'error' => 'Read-only example' }) unless %w[GET HEAD].include?(env['REQUEST_METHOD'])
12
+
13
+ status, body = representation(env['PATH_INFO'])
14
+ result = response(status, body)
15
+ result[2] = [] if env['REQUEST_METHOD'] == 'HEAD'
16
+ result
17
+ end
18
+
19
+ private
20
+
21
+ def representation(path)
22
+ case path
23
+ when '/'
24
+ [200, {
25
+ '_links' => {
26
+ 'self' => { 'href' => '/' },
27
+ 'places' => { 'href' => '/places/' },
28
+ 'place' => { 'href' => '/places/{id}', 'templated' => true },
29
+ 'curies' => [{ 'name' => 'oxford', 'href' => '/docs/{rel}', 'templated' => true }],
30
+ 'oxford:places' => { 'href' => '/places/' }
31
+ }
32
+ }]
33
+ when '/places/'
34
+ [200, { '_links' => { 'self' => { 'href' => '/places/' } },
35
+ '_embedded' => { 'places' => @places } }]
36
+ when '/docs/places'
37
+ [200, { 'description' => 'Archived Oxford place facts in a local HAL collection.' }]
38
+ else
39
+ place = @places.find { |item| item['_links']['self']['href'] == path }
40
+ place ? [200, place] : [404, { 'error' => 'Not included in this example' }]
41
+ end
42
+ end
43
+
44
+ def response(status, body)
45
+ [status, { 'content-type' => 'application/hal+json' }, [JSON.generate(body)]]
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ require 'hyperclient'
2
+
3
+ api = Hyperclient.new(ENV.fetch('OXFORD_URL', 'http://127.0.0.1:9292/'))
4
+ api.places.each do |place|
5
+ puts "#{place.name}: #{place.address}"
6
+ end
7
+
8
+ place = api.place(id: 'oxpoints:23232339')
9
+ puts "Followed primary_place: #{place.primary_place.name}"
10
+ place._embedded.files.each do |file|
11
+ puts "Archived #{file.type} URL (not fetched): #{file.url}"
12
+ end
13
+ puts "CURIE: #{api._links._curies['oxford'].expand('places')}"
@@ -0,0 +1,21 @@
1
+ [
2
+ {
3
+ "_links": {
4
+ "self": { "href": "/places/oxpoints:23232339" },
5
+ "primary_place": { "href": "/places/oxpoints:23232339" }
6
+ },
7
+ "_embedded": {
8
+ "files": [
9
+ {
10
+ "type": "depiction",
11
+ "url": "//mox-static-files.oucs.ox.ac.uk/oxpoints/23232339/depiction/original/ball.jpg"
12
+ }
13
+ ]
14
+ },
15
+ "id": "oxpoints:23232339",
16
+ "name": "Balliol College",
17
+ "address": "Broad Street OX1 3BJ",
18
+ "lat": "51.754425",
19
+ "lon": "-1.257216"
20
+ }
21
+ ]
@@ -0,0 +1,13 @@
1
+ require 'webrick'
2
+ require_relative 'app'
3
+
4
+ app = OxfordExample::App.new
5
+ server = WEBrick::HTTPServer.new(BindAddress: '127.0.0.1', Port: Integer(ENV.fetch('PORT', '9292')))
6
+ server.mount_proc('/') do |request, response|
7
+ status, headers, body = app.call('REQUEST_METHOD' => request.request_method, 'PATH_INFO' => request.path)
8
+ response.status = status
9
+ headers.each { |key, value| response[key] = value }
10
+ response.body = body.join
11
+ end
12
+ %w[INT TERM].each { |signal| trap(signal) { server.shutdown } }
13
+ server.start
@@ -8,10 +8,11 @@ Spinach.config[:failure_exceptions] << Minitest::Assertion
8
8
 
9
9
  class Spinach::FeatureSteps
10
10
  include Minitest::Assertions
11
+
11
12
  attr_accessor :assertions
12
13
 
13
14
  def initialize(*args)
14
- super(*args)
15
+ super
15
16
  self.assertions = 0
16
17
  end
17
18
  end
@@ -190,10 +190,19 @@ module Hyperclient
190
190
  end
191
191
 
192
192
  def http_method(method, body = nil)
193
- @resource = begin
194
- response = @entry_point.connection.run_request(method, _url, body, nil)
195
- Resource.new(response.body, @entry_point, response)
196
- end
193
+ response = @entry_point.connection.run_request(method, _url, body, nil)
194
+ resource = Resource.new(response.body, @entry_point, response)
195
+
196
+ # Only cache GET responses. Caching the response of a mutating
197
+ # request (POST/PUT/PATCH/DELETE) as `@resource` would make
198
+ # subsequent delegated calls (e.g. `#each`, `method_missing`) see
199
+ # the stale mutation response instead of fetching a fresh
200
+ # resource, requiring users to create a new client between a
201
+ # `_post` and a `_get`. See #107.
202
+ @resource = resource if method == :get
203
+ @delegate = nil
204
+
205
+ resource
197
206
  end
198
207
  end
199
208
  end
@@ -66,6 +66,18 @@ module Hyperclient
66
66
  send(name) if respond_to?(name)
67
67
  end
68
68
 
69
+ # Public: Returns the attributes of the Resource as a Hash.
70
+ #
71
+ # Delegates explicitly (rather than relying on method_missing) since
72
+ # Array also defines #to_h (but not #to_hash), which meant the
73
+ # `Array.method_defined?(method)` guard in method_missing let #to_hash
74
+ # through but silently swallowed #to_h, returning nil instead of the
75
+ # attributes hash.
76
+ def to_h
77
+ _attributes.to_h
78
+ end
79
+ alias to_hash to_h
80
+
69
81
  def fetch(key, *args)
70
82
  return self[key] if respond_to?(key)
71
83
 
@@ -1,3 +1,3 @@
1
1
  module Hyperclient
2
- VERSION = '2.0.0'.freeze
2
+ VERSION = '2.1.0'.freeze
3
3
  end
@@ -36,5 +36,14 @@ module Hyperclient
36
36
  it 'is a collection' do
37
37
  _(Attributes.ancestors).must_include Collection
38
38
  end
39
+
40
+ describe 'when the representation is not a Hash' do
41
+ it 'wraps the representation as-is instead of filtering reserved keys' do
42
+ representation = 'not a hash'
43
+ attributes = Attributes.new(representation)
44
+
45
+ _(attributes.instance_variable_get(:@collection)).must_equal representation
46
+ end
47
+ end
39
48
  end
40
49
  end
@@ -41,6 +41,28 @@ module Hyperclient
41
41
  end
42
42
  end
43
43
 
44
+ describe '#to_h' do
45
+ it 'returns the wrapped collection as a hash' do
46
+ _(collection.to_h).must_be_kind_of Hash
47
+ end
48
+
49
+ it 'returns the same result as #to_hash' do
50
+ _(collection.to_h).must_equal(collection.to_hash)
51
+ end
52
+ end
53
+
54
+ describe '#to_s' do
55
+ it 'returns the wrapped collection as a hash' do
56
+ _(collection.to_s).must_be_kind_of Hash
57
+ end
58
+ end
59
+
60
+ describe '#method_missing' do
61
+ it 'raises an error for missing keys' do
62
+ _(proc { collection.missing_key }).must_raise RuntimeError
63
+ end
64
+ end
65
+
44
66
  describe 'include?' do
45
67
  it 'returns true for keys that exist' do
46
68
  _(collection.include?('_links')).must_equal true
@@ -34,5 +34,12 @@ module Hyperclient
34
34
  _(curie.expand('thumbnail')).must_equal '/images/thumbnail'
35
35
  end
36
36
  end
37
+
38
+ describe 'inspect' do
39
+ it 'outputs a custom-friendly output' do
40
+ _(curie.inspect).must_include 'Curie'
41
+ _(curie.inspect).must_include({ 'name' => 'image', 'href' => '/images/{rel}', 'templated' => true }.to_s)
42
+ end
43
+ end
37
44
  end
38
45
  end
@@ -21,6 +21,7 @@ module Hyperclient
21
21
  it 'can update headers after a connection has been constructed' do
22
22
  _(entry_point.connection).must_be_kind_of Faraday::Connection
23
23
  entry_point.headers.update('Content-Type' => 'application/foobar')
24
+
24
25
  _(entry_point.headers['Content-Type']).must_equal 'application/foobar'
25
26
  end
26
27
 
@@ -28,6 +29,7 @@ module Hyperclient
28
29
  _(entry_point.connection).must_be_kind_of Faraday::Connection
29
30
  entry_point.connection.use Faraday::Request::Instrumentation
30
31
  handlers = entry_point.connection.builder.handlers
32
+
31
33
  _(handlers).must_include Faraday::Request::Instrumentation
32
34
  end
33
35
 
@@ -51,6 +53,19 @@ module Hyperclient
51
53
  _(entry_point.connection).must_be_kind_of Faraday::Connection
52
54
  _(-> { entry_point.connection {} }).must_raise ConnectionAlreadyInitializedError
53
55
  end
56
+
57
+ it 'can set the faraday block before a connection has been constructed' do
58
+ block = proc { |conn| conn.use Faraday::Request::Instrumentation }
59
+ entry_point.faraday_block = block
60
+
61
+ _(entry_point.faraday_block).must_equal block
62
+ end
63
+
64
+ it 'raises a ConnectionAlreadyInitializedError if attempting to modify ' \
65
+ 'the faraday block after a connection has been constructed' do
66
+ _(entry_point.connection).must_be_kind_of Faraday::Connection
67
+ _(-> { entry_point.faraday_block = proc {} }).must_raise ConnectionAlreadyInitializedError
68
+ end
54
69
  end
55
70
 
56
71
  describe 'initialize' do
@@ -136,6 +151,7 @@ module Hyperclient
136
151
 
137
152
  it 'creates a Faraday connection with the default block' do
138
153
  handlers = entry_point.connection.builder.handlers
154
+
139
155
  _(handlers).wont_include Faraday::Response::RaiseError
140
156
  _(handlers).wont_include Faraday::FollowRedirects
141
157
  _(handlers).must_include Faraday::Request::Json
@@ -178,5 +194,11 @@ module Hyperclient
178
194
  end
179
195
  end
180
196
  end
197
+
198
+ describe ConnectionAlreadyInitializedError do
199
+ it 'has a descriptive message' do
200
+ _(ConnectionAlreadyInitializedError.new.message).must_equal 'The connection has already been initialized.'
201
+ end
202
+ end
181
203
  end
182
204
  end
@@ -32,6 +32,7 @@ module Hyperclient
32
32
 
33
33
  describe 'plain link' do
34
34
  let(:plain_link) { links.self }
35
+
35
36
  it 'must be correct' do
36
37
  _(plain_link._url).must_equal '/productions/1'
37
38
  end
@@ -39,6 +40,7 @@ module Hyperclient
39
40
 
40
41
  describe 'templated link' do
41
42
  let(:templated_link) { links.search }
43
+
42
44
  it 'must expand' do
43
45
  _(templated_link._expand(search: 'gizmos')._url).must_equal '/productions/1?categories=gizmos'
44
46
  end
@@ -47,6 +49,7 @@ module Hyperclient
47
49
  describe 'curied link collection' do
48
50
  let(:curied_link) { links['image:thumbnail'] }
49
51
  let(:curie) { links._curies['image'] }
52
+
50
53
  it 'must expand' do
51
54
  _(curied_link._expand(version: 'small')._url).must_equal '/images/thumbnails/small.jpg'
52
55
  end
@@ -71,9 +74,16 @@ module Hyperclient
71
74
 
72
75
  describe 'null link value' do
73
76
  let(:null_link) { links.null_link }
77
+
74
78
  it 'must be nil' do
75
79
  _(null_link).must_be_nil
76
80
  end
77
81
  end
82
+
83
+ describe 'invalid collection' do
84
+ it 'raises an error when the collection does not respond to collect' do
85
+ _(proc { LinkCollection.new('invalid', {}, entry_point) }).must_raise RuntimeError
86
+ end
87
+ end
78
88
  end
79
89
  end