Skip to content

Commit 276b7d1

Browse files
dblockCopilot
andcommitted
Replace retired Splines demo with a local Oxford HAL example
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7c93d1 commit 276b7d1

10 files changed

Lines changed: 232 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### 2.0.1 (Next)
44

5+
* [#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).
56
* [#298](https://github.com/codegram/hyperclient/pull/300): Upgraded RuboCop to 1.80.2 - [@dblock](https://github.com/dblock).
67
* [#298](https://github.com/codegram/hyperclient/pull/298): Upgraded RuboCop to 1.63.5 - [@dblock](https://github.com/dblock).
78
* [#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).

‎Gemfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ group :development, :test do
2020
gem 'rubocop-rake', require: false
2121
gem 'simplecov', require: false
2222
gem 'simplecov-lcov', require: false
23+
gem 'webrick', '~> 1.8'
2324
end
2425

2526
group :test do

‎README.md‎

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Hyperclient is a Hypermedia API client written in Ruby. It fully supports [JSON
2424

2525
# Usage
2626

27-
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).
27+
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).
2828

2929
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+).
3030

@@ -35,21 +35,21 @@ Create an API client.
3535
```ruby
3636
require 'hyperclient'
3737

38-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api')
38+
api = Hyperclient.new('http://127.0.0.1:9292/')
3939
```
4040

4141
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.
4242

4343
```ruby
44-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
44+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
4545
client.headers['Access-Token'] = 'token'
4646
end
4747
```
4848

4949
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.
5050

5151
```ruby
52-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
52+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
5353
client.connection do |conn|
5454
conn.use Faraday::Request::Instrumentation
5555
end
@@ -59,7 +59,7 @@ end
5959
You can pass options to the Faraday connection block in the `connection` block:
6060

6161
```ruby
62-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
62+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
6363
client.connection(ssl: { verify: false }) do |conn|
6464
conn.use Faraday::Request::Instrumentation
6565
end
@@ -69,15 +69,15 @@ end
6969
Or when using the default connection configuration you can use `faraday_options`:
7070

7171
```ruby
72-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
72+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
7373
client.faraday_options = { ssl: { verify: false } }
7474
end
7575
```
7676

7777
You can build a new Faraday connection block without inheriting default middleware by specifying `default: false` in the `connection` block.
7878

7979
```ruby
80-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
80+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
8181
client.connection(default: false) do |conn|
8282
conn.request :json
8383
conn.response :json, content_type: /\bjson$/
@@ -93,7 +93,7 @@ You can modify headers or specify authentication after a connection has been cre
9393
```ruby
9494
require 'faraday/digestauth'
9595

96-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
96+
api = Hyperclient.new('http://127.0.0.1:9292/') do |client|
9797
client.connection(default: false) do |conn|
9898
conn.request :digest, 'username', 'password'
9999
conn.request :json
@@ -106,7 +106,7 @@ end
106106
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).
107107

108108
```ruby
109-
api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api')
109+
api = Hyperclient.new('http://127.0.0.1:9292/')
110110
api.connection.use :http_cache
111111
```
112112

@@ -115,59 +115,65 @@ api.connection.use :http_cache
115115
Hyperclient will fetch and discover the resources from your API and automatically paginate when possible.
116116

117117
```ruby
118-
api.splines.each do |spline|
119-
puts "A spline with ID #{spline.uuid}."
118+
api.places.each do |place|
119+
puts "#{place.name} has ID #{place.id}."
120120
end
121121
```
122122

123123
Other methods, including `[]` and `fetch` are also available
124124

125125
```ruby
126-
api.splines.each do |spline|
127-
puts "A spline with ID #{spline[:uuid]}."
128-
puts "Maybe with reticulated: #{spline.fetch(:reticulated, '-- no reticulated')}"
126+
api.places.each do |place|
127+
puts "A place with ID #{place[:id]}."
128+
puts "Address: #{place.fetch(:address, '-- no address')}"
129129
end
130130
```
131131

132132
## Links and Embedded Resources
133133

134-
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`.
134+
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:
135135

136136
```ruby
137-
api._links.splines
137+
api._links.places._embedded.places.each do |place|
138+
puts place.name
139+
end
140+
141+
place = api.place(id: 'oxpoints:23232339')
142+
puts place.primary_place.name # follows a relative link to Balliol College
143+
puts place._embedded.files.first.url # reads embedded metadata, without fetching the image
138144
```
139145

140146
## Templated Links
141147

142-
Templated links require variables to be expanded. For example, the demo API has a link called "spline" that requires a spline "uuid".
148+
Templated links require variables to be expanded. The local demo adds a `place` link that accepts an Oxford place ID.
143149

144150
```ruby
145-
spline = api.spline(uuid: 'uuid')
146-
puts "Spline #{spline.uuid} is #{spline.reticulated ? 'reticulated' : 'not reticulated'}."
151+
place = api.place(id: 'oxpoints:23232339')
152+
puts place.name # => Balliol College
147153
```
148154

149-
Invoking `api.spline(uuid: 'uuid').reticulated` is equivalent to `api._links.spline._expand(uuid: 'uuid')._resource._attributes.reticulated`.
155+
Invoking `api.place(id: 'oxpoints:23232339').name` is equivalent to `api._links.place._expand(id: 'oxpoints:23232339')._resource._attributes.name`.
150156

151157
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`.
152158

153159
## Curies
154160

155-
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.
161+
Curies provide compact names for relation documentation. The local demo adds an `oxford` CURIE and an `oxford:places` relation.
156162

157163
```ruby
158-
puts spline['image:thumbnail'] # => https://grape-with-roar.herokuapp.com/api/splines/uuid/images/thumbnail.jpg
159-
puts spline.links._curies['image'].expand('thumbnail') # => /docs/images/thumbnail
164+
puts api._links['oxford:places']._embedded.places.first.name # => Balliol College
165+
puts api._links._curies['oxford'].expand('places') # => /docs/places
160166
```
161167

162168
## Attributes
163169

164170
Resource attributes can also be accessed as a hash.
165171

166172
```ruby
167-
puts spline.to_h # => {"uuid" => "uuid", "reticulated" => true}
173+
puts place.to_h['name'] # => Balliol College
168174
```
169175

170-
The above is equivalent to `spline._attributes.to_h`.
176+
`place.to_h` is equivalent to `place._attributes.to_h`.
171177

172178
## HTTP
173179

@@ -183,31 +189,27 @@ api._response.body
183189
Other methods, including `_head` or `_options` are also available.
184190

185191
```ruby
186-
spline = api.spline(uuid: 'uuid')
187-
spline._head
188-
spline._options
192+
place = api.place(id: 'oxpoints:23232339')
193+
place._head
189194
```
190195

191-
Invoke `_post` to create resources.
196+
The Oxford demo is read-only and rejects writes with HTTP 405. On a writable API, invoke `_post` to create resources:
192197

193198
```ruby
194-
splines = api.splines
195-
splines._post(uuid: 'new uuid', reticulated: false)
199+
api.places._post(name: 'New place')
196200
```
197201

198202
Invoke `_put` or `_patch` to update resources.
199203

200204
```ruby
201-
spline = api.spline(uuid: 'uuid')
202-
spline._put(reticulated: true)
203-
spline._patch(reticulated: true)
205+
place._put(name: 'Updated place')
206+
place._patch(name: 'Updated place')
204207
```
205208

206209
Invoke `_delete` to destroy a resource.
207210

208211
```
209-
spline = api.spline(uuid: 'uuid')
210-
spline._delete
212+
place._delete
211213
```
212214

213215
HTTP methods always return a new instance of Resource.
@@ -233,13 +235,13 @@ describe Acme::Api do
233235
end
234236
end
235237

236-
it 'splines returns 3 splines by default' do
237-
expect(client.splines.count).to eq 3
238+
it 'returns a place' do
239+
expect(client.places.first.name).to eq 'Balliol College'
238240
end
239241
end
240242
```
241243

242-
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).
244+
For a complete offline example, see the [Oxford demo smoke test](test/oxford_example_test.rb).
243245

244246
# Reference
245247

‎examples/oxford/README.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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`.

‎examples/oxford/app.rb‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

‎examples/oxford/client.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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')}"

‎examples/oxford/places.json‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
]

‎examples/oxford/server.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)