Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 41 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+).

Expand All @@ -35,21 +35,21 @@ 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
```

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
Expand All @@ -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
Expand All @@ -69,15 +69,15 @@ 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
```

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$/
Expand All @@ -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
Expand All @@ -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
```

Expand All @@ -115,59 +115,65 @@ 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

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

Expand All @@ -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.
Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions examples/oxford/README.md
Original file line number Diff line number Diff line change
@@ -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`.
48 changes: 48 additions & 0 deletions examples/oxford/app.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions examples/oxford/client.rb
Original file line number Diff line number Diff line change
@@ -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')}"
21 changes: 21 additions & 0 deletions examples/oxford/places.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
13 changes: 13 additions & 0 deletions examples/oxford/server.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading