Skip to content

mountain-pass/waychaser

main
Switch branches/tags
Code

waychaser

Client library for HATEOAS level 3 RESTful APIs that provide hypermedia controls using:

This isomorphic library is compatible with Node.js 12.x, 14.x and 16.x, Chrome, Firefox, Safari and Edge.

Node.js
Node.js
Chrome
Chrome
Firefox
Firefox
Safari
Safari
Edge
Edge
iOS
iOS
Android
Android
12.x, 14.x, 16.x latest version latest version latest version latest version latest version latest version

FOSSA Status

ToC

Usage

Node.js

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`
}

Browser

<script
  type="text/javascript"
  src="https://unpkg.com/@mountainpass/waychaser@5.0.11"
></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>

Getting the response

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

Getting the response body

WayChaser makes the response body available via the body() async method.

For example

const responseUrl = await apiResource.body()

Requesting linked resources

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.

Multiple links with the same relationship

Resources can have multiple links with the same relationship, 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' })

Forms

Query forms

Support for query forms is provided via:

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'
})

Path parameter forms

Support for query forms is provided via:

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'
})

Request body forms

Support for request body forms is provided via:

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 use
  • params* - used to specify the fields the form expects
  • accept* - used to specify the media-types that can be used to send the body as per, RFC7231 and defaulting to application/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

DELETE, POST, PUT, PATCH

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.

Examples

HAL

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 👍

Siren

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 POSTing data and DELETEing 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'))

Upgrading from 1.x to 2.x

Removal of Loki

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.

Operation count

Previously you could get the number of operations on a resource by calling

apiResource.count()

For 2.x, replace this with

apiResource.length

Finding operations

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

Upgrading from 2.x to 3.x

Accept Header

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.

Handlers

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.

Error responses

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
}

Invoking missing operations

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.

Handling location headers

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.

Upgrading from 3.x to 4.x

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()

Problem vs WayChaserResponse

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:

  1. 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
  2. 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
  1. 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