Skip to content

Commit

Permalink
feat(siren): added support for Siren actions
Browse files Browse the repository at this point in the history
  • Loading branch information
tompahoward committed Jan 15, 2021
1 parent 36afccb commit 518c097
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 29 deletions.
134 changes: 113 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ This [isomorphic](https://en.wikipedia.org/wiki/Isomorphic_JavaScript) library i
- [Getting the response body](#getting-the-response-body)
- [Requesting linked resources](#requesting-linked-resources)
- [Multiple links with the same relationship](#multiple-links-with-the-same-relationship)
- [Forms](#forms)
- [DELETE, POST, PUT, PATCH](#delete-post-put-patch)
- [Forms](#forms)
- [Query forms](#query-forms)
- [Path parameter forms](#path-parameter-forms)
- [Request body forms](#request-body-forms)
- [DELETE, POST, PUT, PATCH](#delete-post-put-patch)

# Usage

Expand Down Expand Up @@ -116,21 +119,21 @@ For instance, if the `apiResource` we loaded above has a `next` `link` like any

**Link header:**
```
Link: <https://waychaser.io/example?p=2>; rel="next";
Link: <https://api.waychaser.io/example?p=2>; rel="next";
```
**HAL**
```json
{
"_links": {
"next": { "href": "https://waychaser.io/example?p=2" }
"next": { "href": "https://api.waychaser.io/example?p=2" }
}
}
```
**Siren**
```json
{
"links": [
{ "rel": [ "next" ], "href": "https://waychaser.io/example?p=2" },
{ "rel": [ "next" ], "href": "https://api.waychaser.io/example?p=2" },
]
}
```
Expand Down Expand Up @@ -174,55 +177,144 @@ If you know the `name` of the resource, then waychaser can load it using the fol
const firstResource = await apiResource.invoke({ rel: 'item', name: 'first' })
```

### Forms
## Forms

Support for forms is provided via [RFC6570](https://tools.ietf.org/html/rfc6570) URI Templates in [`link-template`](https://mnot.github.io/I-D/link-template/) headers, and [HAL](https://tools.ietf.org/html/draft-kelly-json-hal-08) `_link`s.
### Query forms

NOTE: Siren action support is coming soon.
Support for query forms is provided via:
- [RFC6570](https://tools.ietf.org/html/rfc6570) URI Templates in:
- [`link-template`](https://mnot.github.io/I-D/link-template/) headers, and
- [HAL](https://tools.ietf.org/html/draft-kelly-json-hal-08) `_link`s.

For instance if our resource has either of the following

For instance if our resource has either of the following links

**Link header:**
**Link-Template header:**
```
Link-Template: <https://waychaser.io/search{?q}>; rel="search";
Link-Template: <https://api.waychaser.io/search{?q}>; rel="search";
```
**HAL**
```json
{
"_links": {
"search": { "href": "https://waychaser.io/search{?q}" }
"search": { "href": "https://api.waychaser.io/search{?q}" }
}
}
```

Then waychaser can execute a search with the following code
Then waychaser can execute a search for "waychaser" with the following code

```js
const searchResultsResource = await apiResource.invoke('search', {
q: 'waychaser'
})
```
## DELETE, POST, PUT, PATCH

Waychaser supports `Link` and `Link-Template` headers that include `method` properties, to specify the HTTP
method the client must use to execute the relationship.
### Path parameter forms

Support for query forms is provided via:
- [RFC6570](https://tools.ietf.org/html/rfc6570) URI Templates in:
- [`link-template`](https://mnot.github.io/I-D/link-template/) headers, and
- [HAL](https://tools.ietf.org/html/draft-kelly-json-hal-08) `_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**
```json
{
"_links": {
"item": { "href": "https://api.waychaser.io/users{/username}" }
}
}
```

Then waychaser can retrieve the user with the `username` "waychaser" with the following code

```js
const userResource = await apiResource.invoke('item', {
username: 'waychaser'
})
```

### Request body forms

Support for request body forms is provided via:
- An extended form of [`link-template`](https://mnot.github.io/I-D/link-template/) headers, and
- Siren `actions`.

To support request body forms with [`link-template`](https://mnot.github.io/I-D/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](https://tools.ietf.org/html/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](https://tools.ietf.org/html/rfc8288#section-3.4.2) as per
([RFC8288](https://tools.ietf.org/html/rfc8288)) Link Headers. Don't worry, libraries like
[http-link-header](https://www.npmjs.com/package/http-link-header) can do this encoding for you.

**Siren**
```json
{
"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

```js
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](https://tools.ietf.org/html/rfc8288#section-2.1.2) 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://waychaser.io/example/some-resource>; rel="https://waychaser.io/rel/delete"; method="DELETE";
Link: <https://api.waychaser.io/example/some-resource>; rel="https://api.waychaser.io/rel/delete"; method="DELETE";
```

Then the following code

```js
const deletedResource = await apiResource.invoke('https://waychaser.io/rel/delete')
const deletedResource = await apiResource.invoke('https://api.waychaser.io/rel/delete')
```

will send a HTTP `DELETE` to `https://waychaser.io/example/some-resource`.
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](https://tools.ietf.org/html/rfc8288)) or [Link-Template](https://mnot.github.io/I-D/link-template/) headers
and waychaser's behaviour in relation to the `method` property will be incompatible with servers that use this property
and waychaser's behaviour in relation to the `method` property will be incompatible with servers that use this parameter
for another purpose.
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
- [ ] add support for warning about deprecated `_links`
- add support for Siren
- [x] add support for `links`
- [ ] add support for `actions`
- [x] add support for `actions`
- [ ] add support for sub-entities as embedded links
- [ ] add support for sub-entities as embedded representations
- [ ] add 404 equivalent for when trying to invoke a relationship that doesn't exist
Expand Down
58 changes: 51 additions & 7 deletions src/test/resource.steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function sendResponse (
) {
let halLinks
let sirenLinks
let sirenActions

switch (mediaType) {
case 'application/json':
if (links) {
Expand Down Expand Up @@ -77,18 +79,51 @@ function sendResponse (
{ "rel": [ "next" ], "href": "http://api.x.io/orders/43" }
]
*/
sirenLinks = []
links.refs.forEach(link => {
sirenLinks.push({ rel: [link.rel], href: link.uri })
})
if (links) {
sirenLinks = []
links.refs.forEach(link => {
sirenLinks.push({ rel: [link.rel], href: link.uri })
})
}
/*
rel: relationship,
uri: dynamicUri,
method: method,
...(Object.keys(bodyParameters).length > 0 && {
'params*': { value: JSON.stringify(bodyParameters) }
}),
...(accept && {
'accept*': { value: accept }
}) */
if (linkTemplates) {
sirenActions = []
linkTemplates.refs.forEach(link => {
const bodyParameters = JSON.parse(link['params*'].value)

const sirenBodyParameters = Object.keys(bodyParameters).map(key => {
return { name: key }
})
sirenActions.push({
name: link.rel,
href: link.uri,
method: link.method,
...(link['accept*'] && { type: link['accept*'].value }),
...(link['params*'] && { fields: sirenBodyParameters })
})
})
}
break
}
response.header('content-type', mediaType)
response.status(status).send({
const responseBody = {
status,
...(mediaType === MediaTypes.HAL && { _links: halLinks }),
...(mediaType === MediaTypes.SIREN && { links: sirenLinks })
})
...(mediaType === MediaTypes.SIREN && {
links: sirenLinks,
actions: sirenActions
})
}
response.status(status).send(responseBody)
}

function filterParameters (parameters, type) {
Expand Down Expand Up @@ -546,6 +581,15 @@ Given(
}
)

Given(
'a Siren resource with a {string} operation with the {string} method that returns the following {string} provided parameters and the content type',
async function (relationship, method, contentType, dataTable) {
this.currentResourceRoute = await createRandomDynamicResourceRoute.bind(
this
)(relationship, method, dataTable.hashes(), contentType, MediaTypes.SIREN)
}
)

Given(
'a HAL resource with a {string} operation that returns that resource and has the following curies',
async function (relationship, curies) {
Expand Down
23 changes: 23 additions & 0 deletions src/test/siren.feature
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,26 @@ Feature: Invoke Siren Operation
When waychaser successfully loads the first resource in the list
And invokes each of the "next" operations in turn 3 times
Then the last resource returned will be the last item in the list

Scenario Outline: Invoke operation - body
Given a Siren resource with a "https://waychaser.io/rel/pong" operation with the "<METHOD>" method that returns the following "<CONTENT-TYPE>" provided parameters and the content type
| NAME | TYPE |
| ping | <TYPE> |
When waychaser successfully loads that resource
And we invoke the "https://waychaser.io/rel/pong" operation with the input
| ping | pong |
Then resource returned will contain only
| content-type | <CONTENT-TYPE> |
| ping | pong |

Examples:
| METHOD | TYPE | CONTENT-TYPE |
| POST | body | application/x-www-form-urlencoded |
| POST | body | application/json |
| POST | body | multipart/form-data |
| PUT | body | application/x-www-form-urlencoded |
| PUT | body | application/json |
| PUT | body | multipart/form-data |
| PATCH | body | application/x-www-form-urlencoded |
| PATCH | body | application/json |
| PATCH | body | multipart/form-data |
45 changes: 45 additions & 0 deletions src/waychaser.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,36 @@ function mapSirenLinkToLinkHeader (relationship, link) {
}
}

/**
* @param action
*/
function mapSirenActionToLinkHeader (action) {
const { name, href, fields, type, ...otherProperties } = action
const bodyParameters = {}
fields?.forEach(parameter => {
bodyParameters[parameter.name] = {}
})
return {
rel: name,
uri: href,
...(fields && { 'params*': { value: JSON.stringify(bodyParameters) } }),
...(type && {
'accept*': { value: type }
}),
...otherProperties
}
/*
rel: relationship,
uri: dynamicUri,
method: method,
...(Object.keys(bodyParameters).length > 0 && {
'params*': { value: JSON.stringify(bodyParameters) }
}),
...(accept && {
'accept*': { value: accept }
}) */
}

/**
* @param operations
* @param _links
Expand All @@ -185,6 +215,20 @@ function loadSirenOperations (operations, _links, callingContext) {
addLinksToOperations(operations, links, callingContext)
}

/**
* @param operations
* @param actions
* @param callingContext
*/
function loadSirenActionOperations (operations, actions, callingContext) {
const links = new LinkHeader()
actions?.forEach(action => {
const mappedLink = mapSirenActionToLinkHeader(action)
links.set(mappedLink)
})

addLinksToOperations(operations, links, callingContext)
}
class Operation {
constructor (callingContext) {
logger.waychaser(
Expand Down Expand Up @@ -314,6 +358,7 @@ const waychaser = {
break
case MediaTypes.SIREN:
loadSirenOperations(this.operations, body.links, response)
loadSirenActionOperations(this.operations, body.actions, response)
break
default:
break
Expand Down

0 comments on commit 518c097

Please sign in to comment.