From 3e21bf4c6c82c7fa0e1aab2f8efe4176262cbaa0 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 14 Oct 2026 17:32:49 -0400 Subject: [PATCH] Replace retired Splines demo with a local Oxford HAL example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + Gemfile | 1 + README.md | 80 +++++++++++++++++++------------------ examples/oxford/README.md | 42 +++++++++++++++++++ examples/oxford/app.rb | 48 ++++++++++++++++++++++ examples/oxford/client.rb | 13 ++++++ examples/oxford/places.json | 21 ++++++++++ examples/oxford/server.rb | 13 ++++++ examples/splines_api.rb | 39 ------------------ test/oxford_example_test.rb | 52 ++++++++++++++++++++++++ 10 files changed, 232 insertions(+), 78 deletions(-) create mode 100644 examples/oxford/README.md create mode 100644 examples/oxford/app.rb create mode 100644 examples/oxford/client.rb create mode 100644 examples/oxford/places.json create mode 100644 examples/oxford/server.rb delete mode 100644 examples/splines_api.rb create mode 100644 test/oxford_example_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ffaaa..6f749a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### 2.0.1 (Next) +* [#328](https://github.com/codegram/hyperclient/pull/328): Replaced the retired Splines demo with a local read-only Oxford HAL example using archived place facts - [@dblock](https://github.com/dblock). * [#298](https://github.com/codegram/hyperclient/pull/300): Upgraded RuboCop to 1.80.2 - [@dblock](https://github.com/dblock). * [#298](https://github.com/codegram/hyperclient/pull/298): Upgraded RuboCop to 1.63.5 - [@dblock](https://github.com/dblock). * [#319](https://github.com/codegram/hyperclient/pull/319): Fixed `Resource#to_h` returning `nil` instead of the same result as `#to_hash` - [@dblock](https://github.com/dblock). diff --git a/Gemfile b/Gemfile index fb072d5..71ad2d8 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ group :development, :test do gem 'rubocop-rake', require: false gem 'simplecov', require: false gem 'simplecov-lcov', require: false + gem 'webrick', '~> 1.8' end group :test do diff --git a/README.md b/README.md index a6dcd93..113bc88 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Hyperclient is a Hypermedia API client written in Ruby. It fully supports [JSON # Usage -The examples in this README use the [Splines Demo API](https://github.com/ruby-grape/grape-with-roar) running [here](https://grape-with-roar.herokuapp.com/api). Use version 1.x with Faraday 1.x, and version 2.x with Faraday 2.x. If you're upgrading from a previous version, please make sure to read [UPGRADING](UPGRADING.md). +The examples in this README use the [local Oxford HAL demo](examples/oxford/README.md), based on historical Oxford place facts. Start it with `bundle exec ruby examples/oxford/server.rb`, then run `bundle exec ruby -Ilib examples/oxford/client.rb` in another terminal. No external service is needed. Use version 1.x with Faraday 1.x, and version 2.x with Faraday 2.x. If you're upgrading from a previous version, please make sure to read [UPGRADING](UPGRADING.md). Hyperclient is tested with JSON 2.x on Ruby 2.7, both JSON 2.x and 3.x on Ruby 3.2, and JSON 3.x on newer Rubies. JSON 3.x requires a Faraday release with keyword parser options support (2.14.4+). @@ -35,13 +35,13 @@ Create an API client. ```ruby require 'hyperclient' -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') +api = Hyperclient.new('http://127.0.0.1:9292/') ``` By default, Hyperclient adds `application/hal+json` as `Content-Type` and `Accept` headers. It will also send requests as JSON and parse JSON responses. Specify additional headers or authentication if necessary. ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.headers['Access-Token'] = 'token' end ``` @@ -49,7 +49,7 @@ end Hyperclient constructs a connection using typical [Faraday](http://github.com/lostisland/faraday) middleware for handling JSON requests and responses. You can specify additional Faraday middleware if necessary. ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.connection do |conn| conn.use Faraday::Request::Instrumentation end @@ -59,7 +59,7 @@ end You can pass options to the Faraday connection block in the `connection` block: ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.connection(ssl: { verify: false }) do |conn| conn.use Faraday::Request::Instrumentation end @@ -69,7 +69,7 @@ end Or when using the default connection configuration you can use `faraday_options`: ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.faraday_options = { ssl: { verify: false } } end ``` @@ -77,7 +77,7 @@ end You can build a new Faraday connection block without inheriting default middleware by specifying `default: false` in the `connection` block. ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.connection(default: false) do |conn| conn.request :json conn.response :json, content_type: /\bjson$/ @@ -93,7 +93,7 @@ You can modify headers or specify authentication after a connection has been cre ```ruby require 'faraday/digestauth' -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client| +api = Hyperclient.new('http://127.0.0.1:9292/') do |client| client.connection(default: false) do |conn| conn.request :digest, 'username', 'password' conn.request :json @@ -106,7 +106,7 @@ end You can access the Faraday connection directly after it has been created and add middleware to it. As an example, you could use the [faraday-http-cache-middleware](https://github.com/plataformatec/faraday-http-cache). ```ruby -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') +api = Hyperclient.new('http://127.0.0.1:9292/') api.connection.use :http_cache ``` @@ -115,48 +115,54 @@ api.connection.use :http_cache Hyperclient will fetch and discover the resources from your API and automatically paginate when possible. ```ruby -api.splines.each do |spline| - puts "A spline with ID #{spline.uuid}." +api.places.each do |place| + puts "#{place.name} has ID #{place.id}." end ``` Other methods, including `[]` and `fetch` are also available ```ruby -api.splines.each do |spline| - puts "A spline with ID #{spline[:uuid]}." - puts "Maybe with reticulated: #{spline.fetch(:reticulated, '-- no reticulated')}" +api.places.each do |place| + puts "A place with ID #{place[:id]}." + puts "Address: #{place.fetch(:address, '-- no address')}" end ``` ## Links and Embedded Resources -The splines example above followed a link called "splines". While you can, you do not need to specify the HAL navigational structure, including links or embedded resources. Hyperclient will resolve these for you. If you prefer, you can explicitly navigate the link structure via `_links`. In the following example the "splines" link leads to a collection of embedded splines. Invoking `api.splines` is equivalent to `api._links.splines._embedded.splines`. +The example above follows the `places` link to a collection of embedded places. Hyperclient resolves links and embedded resources automatically. You can also navigate explicitly: ```ruby -api._links.splines +api._links.places._embedded.places.each do |place| + puts place.name +end + +place = api.place(id: 'oxpoints:23232339') +puts place.primary_place.name # follows a relative link to Balliol College +puts place._embedded.files.first.url # reads embedded metadata, without fetching the image ``` ## Templated Links -Templated links require variables to be expanded. For example, the demo API has a link called "spline" that requires a spline "uuid". +Templated links require variables to be expanded. The local demo adds a `place` link that accepts an Oxford place ID. ```ruby -spline = api.spline(uuid: 'uuid') -puts "Spline #{spline.uuid} is #{spline.reticulated ? 'reticulated' : 'not reticulated'}." +place = api.place(id: 'oxpoints:23232339') +puts place.name # => Balliol College ``` -Invoking `api.spline(uuid: 'uuid').reticulated` is equivalent to `api._links.spline._expand(uuid: 'uuid')._resource._attributes.reticulated`. +Invoking `api.place(id: 'oxpoints:23232339').name` is equivalent to `api._links.place._expand(id: 'oxpoints:23232339')._resource._attributes.name`. The client is responsible for supplying all the necessary parameters. Templated links don't do any strict parameter name checking and don't support required vs. optional parameters. Parameters not declared by the API will be dropped and will not have any effect when passed to `_expand`. ## Curies -Curies are a suggested means by which to link documentation of a given resource. For example, the demo API contains very long links to images that use an "images" curie. +Curies provide compact names for relation documentation. The local demo adds an `oxford` CURIE and an `oxford:places` relation. ```ruby -puts spline['image:thumbnail'] # => https://grape-with-roar.herokuapp.com/api/splines/uuid/images/thumbnail.jpg -puts spline.links._curies['image'].expand('thumbnail') # => /docs/images/thumbnail +puts api._links['oxford:places']._embedded.places.first.name # => Balliol College +puts api._links._curies['oxford'].expand('places') # => /docs/places ``` ## Attributes @@ -164,10 +170,10 @@ puts spline.links._curies['image'].expand('thumbnail') # => /docs/images/thumbna Resource attributes can also be accessed as a hash. ```ruby -puts spline.to_h # => {"uuid" => "uuid", "reticulated" => true} +puts place.to_h['name'] # => Balliol College ``` -The above is equivalent to `spline._attributes.to_h`. +`place.to_h` is equivalent to `place._attributes.to_h`. ## HTTP @@ -183,31 +189,27 @@ api._response.body Other methods, including `_head` or `_options` are also available. ```ruby -spline = api.spline(uuid: 'uuid') -spline._head -spline._options +place = api.place(id: 'oxpoints:23232339') +place._head ``` -Invoke `_post` to create resources. +The Oxford demo is read-only and rejects writes with HTTP 405. On a writable API, invoke `_post` to create resources: ```ruby -splines = api.splines -splines._post(uuid: 'new uuid', reticulated: false) +api.places._post(name: 'New place') ``` Invoke `_put` or `_patch` to update resources. ```ruby -spline = api.spline(uuid: 'uuid') -spline._put(reticulated: true) -spline._patch(reticulated: true) +place._put(name: 'Updated place') +place._patch(name: 'Updated place') ``` Invoke `_delete` to destroy a resource. ``` -spline = api.spline(uuid: 'uuid') -spline._delete +place._delete ``` HTTP methods always return a new instance of Resource. @@ -233,13 +235,13 @@ describe Acme::Api do end end - it 'splines returns 3 splines by default' do - expect(client.splines.count).to eq 3 + it 'returns a place' do + expect(client.places.first.name).to eq 'Balliol College' end end ``` -For a complete example refer to [this Splines Demo API test](https://github.com/ruby-grape/grape-with-roar/blob/master/spec/api/splines_endpoint_with_hyperclient_spec.rb). +For a complete offline example, see the [Oxford demo smoke test](test/oxford_example_test.rb). # Reference diff --git a/examples/oxford/README.md b/examples/oxford/README.md new file mode 100644 index 0000000..14007db --- /dev/null +++ b/examples/oxford/README.md @@ -0,0 +1,42 @@ +# Local Oxford HAL Demo + +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. + +From the repository root, install dependencies and start the server: + +```sh +bundle install +bundle exec ruby examples/oxford/server.rb +``` + +In a second terminal: + +```sh +bundle exec ruby -Ilib examples/oxford/client.rb +``` + +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. + +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. + +## Provenance and Adaptations + +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. + +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). + +## Data License + +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. + +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. + +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. + +## Tests + +```sh +bundle exec ruby -Itest test/oxford_example_test.rb +``` + +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`. diff --git a/examples/oxford/app.rb b/examples/oxford/app.rb new file mode 100644 index 0000000..27f128b --- /dev/null +++ b/examples/oxford/app.rb @@ -0,0 +1,48 @@ +require 'json' + +module OxfordExample + # A read-only HAL demo, independent of Oxford's retired API. + class App + def initialize + @places = JSON.parse(File.read(File.join(__dir__, 'places.json'))) + end + + def call(env) + return response(405, { 'error' => 'Read-only example' }) unless %w[GET HEAD].include?(env['REQUEST_METHOD']) + + status, body = representation(env['PATH_INFO']) + result = response(status, body) + result[2] = [] if env['REQUEST_METHOD'] == 'HEAD' + result + end + + private + + def representation(path) + case path + when '/' + [200, { + '_links' => { + 'self' => { 'href' => '/' }, + 'places' => { 'href' => '/places/' }, + 'place' => { 'href' => '/places/{id}', 'templated' => true }, + 'curies' => [{ 'name' => 'oxford', 'href' => '/docs/{rel}', 'templated' => true }], + 'oxford:places' => { 'href' => '/places/' } + } + }] + when '/places/' + [200, { '_links' => { 'self' => { 'href' => '/places/' } }, + '_embedded' => { 'places' => @places } }] + when '/docs/places' + [200, { 'description' => 'Archived Oxford place facts in a local HAL collection.' }] + else + place = @places.find { |item| item['_links']['self']['href'] == path } + place ? [200, place] : [404, { 'error' => 'Not included in this example' }] + end + end + + def response(status, body) + [status, { 'content-type' => 'application/hal+json' }, [JSON.generate(body)]] + end + end +end diff --git a/examples/oxford/client.rb b/examples/oxford/client.rb new file mode 100644 index 0000000..e5da46b --- /dev/null +++ b/examples/oxford/client.rb @@ -0,0 +1,13 @@ +require 'hyperclient' + +api = Hyperclient.new(ENV.fetch('OXFORD_URL', 'http://127.0.0.1:9292/')) +api.places.each do |place| + puts "#{place.name}: #{place.address}" +end + +place = api.place(id: 'oxpoints:23232339') +puts "Followed primary_place: #{place.primary_place.name}" +place._embedded.files.each do |file| + puts "Archived #{file.type} URL (not fetched): #{file.url}" +end +puts "CURIE: #{api._links._curies['oxford'].expand('places')}" diff --git a/examples/oxford/places.json b/examples/oxford/places.json new file mode 100644 index 0000000..892ef40 --- /dev/null +++ b/examples/oxford/places.json @@ -0,0 +1,21 @@ +[ + { + "_links": { + "self": { "href": "/places/oxpoints:23232339" }, + "primary_place": { "href": "/places/oxpoints:23232339" } + }, + "_embedded": { + "files": [ + { + "type": "depiction", + "url": "//mox-static-files.oucs.ox.ac.uk/oxpoints/23232339/depiction/original/ball.jpg" + } + ] + }, + "id": "oxpoints:23232339", + "name": "Balliol College", + "address": "Broad Street OX1 3BJ", + "lat": "51.754425", + "lon": "-1.257216" + } +] diff --git a/examples/oxford/server.rb b/examples/oxford/server.rb new file mode 100644 index 0000000..3268946 --- /dev/null +++ b/examples/oxford/server.rb @@ -0,0 +1,13 @@ +require 'webrick' +require_relative 'app' + +app = OxfordExample::App.new +server = WEBrick::HTTPServer.new(BindAddress: '127.0.0.1', Port: Integer(ENV.fetch('PORT', '9292'))) +server.mount_proc('/') do |request, response| + status, headers, body = app.call('REQUEST_METHOD' => request.request_method, 'PATH_INFO' => request.path) + response.status = status + headers.each { |key, value| response[key] = value } + response.body = body.join +end +%w[INT TERM].each { |signal| trap(signal) { server.shutdown } } +server.start diff --git a/examples/splines_api.rb b/examples/splines_api.rb deleted file mode 100644 index 61d959d..0000000 --- a/examples/splines_api.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'hyperclient' - -puts "Using Hyperclient #{Hyperclient::VERSION} ..." - -# create a new client -api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') - -# enumerate splines -api.splines.each do |spline| - puts spline.uuid - puts " reticulated: #{spline.reticulated ? 'yes' : 'no'}" - puts " thumbnail: #{spline['images:thumbnail']}" -end - -api._links.splines._embedded.splines.each do |_spline| - # ... equivalent to the above -end - -puts '*' * 10 - -# retrieve an existing spline -spline = api.spline(uuid: 123) -puts "Spline #{spline.uuid} is #{spline.reticulated ? 'reticulated' : 'not reticulated'}." - -# puts api._links.spline._expand(uuid: 'uuid')._resource._attributes.reticulated - -# spline.to_h - -# create a new spline -spline = api.splines._post(spline: { reticulated: true }) -puts "Created a #{spline.reticulated ? 'reticulated' : 'unreticulated'} spline #{spline.uuid}." - -# update an existing spline -spline = api.spline(uuid: 123)._put(spline: { reticulated: true }) -puts "Updated spline #{spline.uuid}, now #{spline.reticulated ? 'reticulated' : 'not reticulated'}." - -# delete an existing spline -spline = api.spline(uuid: 123)._delete -puts "Deleted spline #{spline.uuid}." diff --git a/test/oxford_example_test.rb b/test/oxford_example_test.rb new file mode 100644 index 0000000..8ab1693 --- /dev/null +++ b/test/oxford_example_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' +require 'hyperclient' +require 'webmock' +require 'stringio' +require_relative '../examples/oxford/app' + +describe OxfordExample::App do + let(:app) { OxfordExample::App.new } + + before do + WebMock.enable! + WebMock.stub_request(:any, %r{\Ahttp://127\.0\.0\.1:9292/}).to_return do |request| + status, headers, body = app.call('REQUEST_METHOD' => request.method.to_s.upcase, + 'PATH_INFO' => request.uri.path) + { status: status, headers: headers, body: body.join } + end + end + + after do + WebMock.reset! + WebMock.disable! + end + + it 'runs the documented client without external requests' do + output, = capture_io { load File.expand_path('../examples/oxford/client.rb', __dir__) } + + _(output).must_include 'Balliol College: Broad Street OX1 3BJ' + _(output).must_include 'Followed primary_place: Balliol College' + _(output).must_include 'Archived depiction URL (not fetched):' + _(output).must_include 'CURIE: /docs/places' + end + + it 'supports explicit navigation, hash attributes, and embedded resources' do + api = Hyperclient.new('http://127.0.0.1:9292/') + place = api._links.places._embedded.places.first + + _(place[:name]).must_equal 'Balliol College' + _(place.to_h['id']).must_equal 'oxpoints:23232339' + _(place._embedded.files.first.type).must_equal 'depiction' + _(api._links['oxford:places']._embedded.places.first.name).must_equal 'Balliol College' + end + + it 'serves HAL, rejects writes, and reports missing resources' do + status, headers, = app.call('REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/') + + _(status).must_equal 200 + _(headers['content-type']).must_equal 'application/hal+json' + _(app.call('REQUEST_METHOD' => 'POST', 'PATH_INFO' => '/').first).must_equal 405 + _(app.call('REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/missing').first).must_equal 404 + _(app.call('REQUEST_METHOD' => 'HEAD', 'PATH_INFO' => '/').last).must_equal [] + end +end