Client library for HATEOAS level 3 RESTful APIs that provide hypermedia controls using:
- Link (RFC8288) and Link-Template headers.
- HAL
- Siren
This isomorphic library is compatible with Node.js 12.x, 14.x and 16.x, Chrome, Firefox, Safari and Edge.
Node.js |
Chrome |
Firefox |
Safari |
Edge |
iOS |
Android |
---|---|---|---|---|---|---|
12.x, 14.x, 16.x | latest version | latest version | latest version | latest version | latest version | latest version |
- waychaser
- ToC
- Usage
- Examples
- Upgrading from 1.x to 2.x
- Upgrading from 2.x to 3.x
- Upgrading from 3.x to 4.x
npm install @mountainpass/waychaser
import { WayChaser } from '@mountainpass/waychaser'
//...
const waychaser = new WayChaser()
try {
const apiResource = await waychaser.load(apiUrl)
// do something with `apiResource`
} catch (error) {
// do something with `error`
}
<script
type="text/javascript"
src="https://unpkg.com/@mountainpass/[email protected]"
></script>
...
<script type="text/javascript">
var waychaser = new window.waychaser.WayChaser()
waychaser
.load(apiUrl)
.then((apiResource) => {
// do something with `apiResource`
})
.catch((error) => {
// do something with `error`
});
</script>
WayChaser makes it's http requests using fetch
and the Fetch.Response
is available via the response
property.
For example
const responseUrl = apiResource.response.url
WayChaser makes the response body available via the body()
async method.
For example
const responseUrl = await apiResource.body()
Level 3 REST APIs are expected to return links to related resources. WayChaser expects to find these links via RFC 8288 link
headers, link-template
headers, HAL _link
elements or Siren link
elements.
WayChaser provides methods to simplify requesting these linked resources.
For instance, if the apiResource
we loaded above has a next
link
like any of the following:
Link header:
Link: <https://api.waychaser.io/example?p=2>; rel="next";
HAL
{
"_links": {
"next": { "href": "https://api.waychaser.io/example?p=2" }
}
}
Siren
{
"links": [
{ "rel": [ "next" ], "href": "https://api.waychaser.io/example?p=2" },
]
}
then that next
page can be retrieved using the following code
const nextResource = await apiResource.invoke('next')
You don't need to tell waychaser whether to use Link headers, HAL _links
or Siren links
; it will figure it out
based on the resource's media-type. If the media-type is application/hal+json
if will try to parse the links in the
_link
property of the body. If the media-type is application/vnd.siren+json
if will try to parse the links in the
link
property of the body.
Regardless of the resource's media-type, it will always try to parse the links in the Link
and Link-Template
headers.
Resources can have multiple links with the same rel
ationship, such as
HAL
{
"_links": {
"item": [{
"href": "/first_item",
"name": "first"
},{
"href": "/second_item",
"name": "second"
}]
}
}
If you know the name
of the resource, then waychaser can load it using the following code
const firstResource = await apiResource.invoke({ rel: 'item', name: 'first' })
Support for query forms is provided via:
- RFC6570 URI Templates in:
link-template
headers, and- HAL
_link
s.
For instance if our resource has either of the following
Link-Template header:
Link-Template: <https://api.waychaser.io/search{?q}>; rel="search";
HAL
{
"_links": {
"search": { "href": "https://api.waychaser.io/search{?q}" }
}
}
Then waychaser can execute a search for "waychaser" with the following code
const searchResultsResource = await apiResource.invoke('search', {
q: 'waychaser'
})
Support for query forms is provided via:
- RFC6570 URI Templates in:
link-template
headers, and- HAL
_link
s.
For instance if our resource has either of the following
Link-Template header
Link-Template: <https://api.waychaser.io/users{/username}>; rel="item";
HAL
{
"_links": {
"item": { "href": "https://api.waychaser.io/users{/username}" }
}
}
Then waychaser can retrieve the user with the username
"waychaser" with the following code
const userResource = await apiResource.invoke('item', {
username: 'waychaser'
})
Support for request body forms is provided via:
- An extended form of
link-template
headers, and - Siren
actions
.
To support request body forms with link-template
headers, waychaser
supports three additional parameters in the link-template
header:
method
- used to specify the HTTP method to useparams*
- used to specify the fields the form expectsaccept*
- used to specify the media-types that can be used to send the body as per, RFC7231 and defaulting toapplication/x-www-form-urlencoded
If our resource has either of the following:
Link-Template header:
Link-Template: <https://api.waychaser.io/users>;
rel="https://waychaser.io/rels/create-user";
method="POST";
params*=UTF-8'en'%7B%22username%22%3A%7B%7D%7D'
If your wondering what the UTF-8'en'%7B%22username%22%3A%7B%7D%7D'
part is, it's just the JSON {"username":{}}
encoded as an Extension Attribute as per
(RFC8288) Link Headers. Don't worry, libraries like
http-link-header can do this encoding for you.
Siren
{
"actions": [
{
"name": "https://waychaser.io/rels/create-user",
"href": "https://api.waychaser.io/users",
"method": "POST",
"fields": [
{ "name": "username" }
]
}
]
}
Then waychaser can create a new user with the username
"waychaser" with the following code
const createUserResultsResource = await apiResource.invoke('https://waychaser.io/rels/create-user', {
username: 'waychaser'
})
NOTE: The URL https://waychaser.io/rels/create-user
in the above code is NOT the end-point the form is
posted to. That URL is a custom Extension Relation that identifies
the semantics of the operation. In the example above, the form will be posted to https://api.waychaser.io/users
As mentioned above, waychaser supports Link
and Link-Template
headers that include method
properties,
to specify the HTTP method the client must use to execute the relationship.
For instance if our resource has the following link
Link header:
Link: <https://api.waychaser.io/example/some-resource>; rel="https://api.waychaser.io/rel/delete"; method="DELETE";
Then the following code
const deletedResource = await apiResource.invoke('https://waychaser.io/rel/delete')
will send a HTTP DELETE
to https://api.waychaser.io/example/some-resource
.
NOTE: The method
property is not part of the specification for Link
(RFC8288) or Link-Template headers.
This means that if you use waychaser with a server that provides this headers and it uses the method
property
for something else, then you're going to need a custom handler.
The following code demonstrates using waychaser with the REST API for AWS API Gateway to download the 'Error' schema from 'test-waychaser' gateway
import { waychaser, halHandler, MediaTypes } from '@mountainpass/waychaser'
import fetch from 'cross-fetch'
import aws4 from 'aws4'
// AWS makes us sign each request. This is a fetcher that does that automagically for us.
/**
* @param url
* @param options
*/
function awsFetch (url, options) {
const parsedUrl = new URL(url)
const signedOptions = aws4.sign(
Object.assign(
{
host: parsedUrl.host,
path: `${parsedUrl.pathname}?${parsedUrl.searchParams}`,
method: 'GET'
},
options
)
)
return fetch(url, signedOptions)
}
// Now we tell waychaser, to only accept HAL and to use our fetcher.
const awsWayChaser = waychaser.use(halHandler, MediaTypes.HAL).withFetch(awsFetch)
// now we can load the API
const api = await waychaser.load(
'https://apigateway.ap-southeast-2.amazonaws.com/restapis'
)
// then we can find the gateway we're after
const gateway = await api.ops
.filter('item')
.findInRelated({ name: 'test-waychaser' })
// then we can get the models
const models = await gateway.invoke(
'http://docs.aws.amazon.com/apigateway/latest/developerguide/restapi-restapi-models.html'
)
// then we can find the schema we're after
const model = await models.ops
.filter('item')
.findInRelated({ name: 'Error' })
// and now we get the schema
const schema = JSON.parse((await model.body()).schema)
NOTE: While the above is a legit, and it works (here's the test), for full use of the AWS API Gateway REST API, you're going to need a custom handler.
This is because HAL links are supposed retrieved using a HTTP GET, but many of the AWS API Gateway REST API links require using POST, PATCH or DELETE HTTP methods.
But there's nothing in AWS API Gateway links to tell you when to use a different HTTP method. Instead it's communicated out-of-band in AWS API Gateway documentation. If you write a custom handler, please let me know đź‘Ť
While admittedly this is a toy example, the following code demonstrates using waychaser to complete the Hypermedia in the Wizard's Tower text-based adventure game.
But even though it's a game, it shows how waychaser can easily navigate a complex process, including POST
ing data and
DELETE
ing resources.
return waychaser
.load('http://hyperwizard.azurewebsites.net/hywit/void')
.then(current =>
current.invoke('start-adventure', {
name: 'waychaser',
class: 'Burglar',
race: 'waychaser',
gender: 'Male'
})
)
.then(current => {
if (current.response.status <= 500) return current.invoke('related')
else throw new Error('Server Error')
})
.then(current => current.invoke('north'))
.then(current => current.invoke('pull-lever'))
.then(current =>
current.invoke({ rel: 'move', title: 'Cross the bridge.' })
)
.then(current => current.invoke('move'))
.then(current => current.invoke('look'))
.then(current => current.invoke('eat-snacks'))
.then(current => current.invoke('related'))
.then(current => current.invoke('north'))
.then(current => current.invoke('pull-lever'))
.then(current => current.invoke('look'))
.then(current => current.invoke('eat-snacks'))
.then(current => current.invoke('enter'))
.then(current => current.invoke('answer-skull', { master: 'Edsger' }))
.then(current => current.invoke('east'))
.then(current => current.invoke('smash-mirror-1') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-2') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-3') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-4') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-5') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-6') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('smash-mirror-7') || current)
.then(current => current.invoke('related') || current)
.then(current => current.invoke('look'))
.then(current => current.invoke('enter-mirror'))
.then(current => current.invoke('north'))
.then(current => current.invoke('down'))
.then(current => current.invoke('take-book-3'))
Loki is no longer use for storing operations and has been replaced with an subclass of Array
. We originally
introduced Loki it's querying capability, but it turned out to be far to large a dependency.
Previously you could get the number of operations on a resource by calling
apiResource.count()
For 2.x, replace this with
apiResource.length
To find an operation, instead of using
apiResource.operations.findOne(relationship)
// or
apiResource.operations.findOne({ rel: relationship })
// or
apiResource.ops.findOne(relationship)
// or
apiResource.ops.findOne({ rel: relationship })
use
apiResource.operations.find(relationship)
// or
apiResource.operations.find({ rel: relationship })
// or
apiResource.operations.find(operation => {
return operation.rel === relationship
})
// or
apiResource.ops.find(relationship)
// or
apiResource.ops.find({ rel: relationship })
// or
apiResource.ops.find(operation => {
return operation.rel === relationship
})
Additionally when invoking an operation, you can use an array finder function as well. e.g. the following are all equivalent
await apiResource.invoke(relationship)
await apiResource.invoke({ rel: relationship })
await apiResource.invoke(operation => {
return operation.rel === relationship
})
await apiResource.operations.invoke(relationship)
await apiResource.operations.invoke({ rel: relationship })
await apiResource.operations.invoke(operation => {
return operation.rel === relationship
})
await apiResource.ops.invoke(relationship)
await apiResource.ops.invoke({ rel: relationship })
await apiResource.ops.invoke(operation => {
return operation.rel === relationship
})
await apiResource.operations.find(relationship).invoke()
await apiResource.operations.find({ rel: relationship }).invoke()
await apiResource.operations.find(operation => {
return operation.rel === relationship
}).invoke()
await apiResource.ops.find(relationship).invoke()
await apiResource.ops.find({ rel: relationship }).invoke()
await apiResource.ops.find(operation => {
return operation.rel === relationship
}).invoke()
NOTE: When findOne
could not find an operation, null
was returned, whereas when find
cannot find an operation
it returns undefined
waychaser now automatically provides an accept
header in requests.
The accept header can be overridden for individual requests, by including an alternate header.accept
value in the
options
parameter when calling the invoke
method.
The use
method now expects both a handler
and the mediaType
it can handle. WayChaser uses the provided
mediaTypes to automatically generate the accept
request header.
NOTE: Currently waychaser does use the corresponding content-type
header to filter the responses passed to
handlers. THIS MAY CHANGE IN THE FUTURE. Handlers should only process responses that match the mediaType provided
when they are registered using the use
method.
In 2.x waychaser would throw an Error
if response.ok
was false. This is no longer the case as some APIs provide
hypermedia responses for 4xx and 5xx responses.
Code like the following
try {
return apiResource.invoke(relationship)
} catch(error) {
if( error.response ) {
// handle error response...
}
else {
// handle fetch error
}
}
should be replaced with
try {
const resource = await apiResource.invoke(relationship)
if( resource.response.ok ) {
return resource
}
else {
// handle error response...
}
} catch(error) {
// handle fetch error
}
or if there is no special processing needed for error responses
try {
return apiResource.invoke(relationship)
} catch(error) {
// handle fetch error
}
In 2.x invoking an operation that didn't exist would throw an error, leading to code like
const found = apiResource.ops.find(relationship)
if( found ) {
return found.invoke()
}
else {
// handle op missing
}
In 3.x invoking an operation that doesn't exist returns undefined
, allowing for simpler code, as follows
const resource = await apiResource.invoke(relationship)
if( resource === undefined ) {
// handle operation missing
}
or
return apiResource.invoke(relationship) || //... return a default
NOTE: When we say it returns undefined
we actually mean undefined
, NOT a promise the resolves
to undefined
. This is what makes the ...invoke(rel) || default
code possible.
WayChaser 3.x now includes a location
header hander, which will create an operation with the related
relationship.
This allows support for APIs that, when creating a resource (ie using POST), provide a location
to the created
resource in the response, or APIs that, when updating a resource (ie using PUT or PATCH), provide a location
to the
updated resource in the response.
Previously WayChaser provided a default instance via the waychaser
export. This is no longer the case and you will
need to create your own instance using new WayChaser()
Problems can be client side or server side
Client side
- fetch throws exception - No Response
- can't parse response - Has Response
- can parse response, but the type predicate fails - Has Response
Server side
- server returns problem document - Has Response
Response may include links that tell the client how to resolve, so we want it to be a WayChaserResponse
Options:
- invoke returns WayChaserResponse with problem or content
- client uses content !== undefined && problem === undefined to check if the were not problems
- unclear if we got a problem or not
- invoke returns a clean WayChaserResponse with content or a WayChaserProblem with a problem document
- client would need to use instanceOf to differentiate
- clean WayChaserResponse has a response and content (which could be expectedly undefined)
- WayChaserProblem has a problem document and may or may not have a response
- invoke returns a clean WayChaserResponse or a ProblemDocument with optional waychaser response as extention
- client would need to use instanceOf to differentiate
- if server returns PD, then do we wrap the PD? Feels ugly