Skip to content

Commit

Permalink
feat: cache and preconditions
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Sep 24, 2022
1 parent 0d84ad2 commit ed90d77
Show file tree
Hide file tree
Showing 17 changed files with 621 additions and 20 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-experts-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hydrofoil/creta-labs": patch
---

Cache and preconditons
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* [Configuration](knossos/configuration.md "Configuration | Knossos | Creta")
* [Resource URLs](knossos/resource-url.md "Resource URLs | Knossos | Creta")
* Advanced
* [Cache](advanced/caching.md "Cache | Knossos | Kreta")
* [Multi-tenancy](advanced/multi-tenancy.md "Multi-tenancy | Knossos | Creta")
* [Resource hooks](advanced/hooks.md "Resource hooks | Knossos | Creta")
* [Setup from code](knossos/middleware.md "Setup from code | Knossos | Creta")
Expand Down
148 changes: 148 additions & 0 deletions docs/advanced/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
## HTTP cache

> [!WARNING]
> Consider this feature incomplete and potentially unstable. Any details are prone to change in future releases.
Efficiently and accurately using HTTP cache is imperative for the success of real-time web applications, That includes APIs,
and even more so when backed by triple stores whose flexibility often come with a performance penalty.

Creta provides the necessary building blocks to set up web cache but makes certain assumptions of how it can be done.

### Selecting resources to cache

The decision what caching headers to add to a response is made by the Hydra operation associated with the request. Not the
resource type, because any resource can support multiple operations and multiple HTTP methods, each requiring different
cache strategy.

### Default cache for all resources

The default Hydra operation supported by all resources could be extended with a `beforeSend` hook. In the example below,
the server will add a `cache-control` to every response message.

```turtle
prefix code: <https://code.described.at/>
prefix hydra: <http://www.w3.org/ns/hydra/core#>
prefix knossos: <https://hypermedia.app/knossos#>
<>
knossos:supportedBy hydra:Resource ;
hydra:method "GET" ;
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/labyrinth/resource#get> ;
] ;
# add this
knossos:beforeSend
[
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/creta-labs/cache#setHeaders> ;
] ;
code:arguments
[
code:name "cache-control" ;
code:value "max-age=3600, stale-when-revalidate=120" ;
] ;
] ;
.
```

### ETags

To make cache invalidation and other [conditional requests](#conditional-requests) possible, the `setHeaders` before send
hook supports an `etag` parameter. When set to `true`, a hash of the RDF response dataset will be calculated. To ensure
consistency, the dataset will first be serialized to a [canonical form](https://w3c-ccg.github.io/rdf-dataset-canonicalization/spec/),
and then hashed.

For example, to enable etags for an Article class

```turtle
prefix code: <https://code.described.at/>
prefix hydra: <http://www.w3.org/ns/hydra/core#>
prefix knossos: <https://hypermedia.app/knossos#>
</api/Article>
hydra:supportedOperation
[
hydra:method "GET" ;
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/labyrinth/resource#get> ;
] ;
knossos:beforeSend
[
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/creta-labs/cache#setHeaders> ;
] ;
code:arguments
[
code:name "cache-control" ;
code:value "max-age=3600, stale-when-revalidate=120" ;
] ,
[
code:name "etag" ;
code:value true ;
] ;
] ;
] ;
.
```

> [!NOTE]
> There is no "inheritance" of cache settings. Thus, the hook must explicitly repeat the `cache-control` every time, even
> if there is a cache enabled on the [default operation handler](#default-cache-for-all-resources).
#### Strong vs weak ETags

By default, all generated ETags are "weak", which means that they can be used for caching but not for conditional requests.
That is because a "full" representation of a resource is queried from the [union graph](https://patterns.dataincubator.org/book/union-graph.html)
so that they can include triples asserted outside the resource's own named graph. The full representation may also include
inferred triples, depending on the SPARQL endpoint configuration.

Strong ETags are only generated for requests negotiating for [minimal representation](https://www.rfc-editor.org/rfc/rfc7240#section-4.2).
This way ensures that no side effects of other resource changes will affect the ETag, as it must be guaranteed to be
equal when the resource itself is unchanged.

```http request
GET /resource
Prefer: return=minimal
```

> [!TIP]
> Refer to the documentation of [minimal representation loader](../knossos/configuration.md#minimal-representation-loader)
> to see how to set up your own implementation of how the minimal representation is constructed.
### Conditional requests

To take full advantage of ETags, the API must also perform [precondition checks](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests).
They can be used by the clients to determine if cache is fresh (server returns `304` status to `GET`), or avoid the lost
update problem (server rejects update requests when resource has changed since the client had last retrieved it).

To set up, add the preconditions middleware at the `before` extension point.

```turtle
PREFIX schema: <http://schema.org/>
prefix code: <https://code.described.at/>
prefix knossos: <https://hypermedia.app/knossos#>
<>
a knossos:Configuration ;
knossos:middleware
[
schema:name "before" ;
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/creta-labs/cache#preconditions> ;
] ;
] ;
.
```

> [!NOTE]
> By default, precondition headers are required on requests with methods `PUT`, `PATCH` and `DELETE`.
1 change: 1 addition & 0 deletions docs/advanced/code-arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ this includes:
* [Template variable transform](../knossos/collections.md#transforming-variables) (as of `@hydrofoil/[email protected]`)
* [Middleware](../knossos/configuration.md#middleware) (as of `@hydrofoil/[email protected]`)
* [Resource loader](../knossos/configuration.md#resource-loader) (as of `@hydrofoil/[email protected]`)
* [Before send hooks](../knossos/hooks.md#before-send) (as of `@hydrofoil/[email protected]` and `@hydrofoil/[email protected]`)

All code import blocks follow the same pattern. They are objects of their respective property and require at least a
`code:implementedBy` property. For example, a [before save hook](./hooks.md#before-save-hook) could look like:
Expand Down
76 changes: 72 additions & 4 deletions docs/advanced/hooks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Resource hooks

## Before save hook
## Hook arguments

> [!TIP]
> All resource hooks functions below can be parametrised. Arguments are provided by attaching `code:arguments` to hook's node.
> See [here](./code-arguments.md) for more details.
## Before save hooks

> [!API]
> `import type { BeforeSave } from @hydrofoil/knossos/lib/resource`
Expand Down Expand Up @@ -154,9 +160,71 @@ Use `knossos:preprocessResource` to modify the representation of the current res
Finally, `knossos:preprocessResponse` can be used to modify the final contents of the response just before sending it to the client. It is by called when executing the generic `GET` handlers `@hydrofoil/knossos/resource#get` and `@hydrofoil/knossos/collection#get`, and when creating collection members with `@hydrofoil/knossos/collection#CreateMember`.
## Before send hooks
## Hook arguments
> [!API]
> `import type { BeforeSend } from '@hydrofoil/labyrinth/middleware'`
>
> [Open API docs](/api/interfaces/_hydrofoil_labyrinth_lib_middleware_sendResponse.BeforeSend.html)
It is possible to modify the response at the final stage, right before the triples will be sent to the client. A before
send hook, declared on the class' Hydra operations receive the request and request objects, and the dataset itself.
Below is an example of a hook which would set `cache-control` and `etag` headers on responses `GET` requests for articles.
```turtle
PREFIX code: <https://code.described.at/>
PREFIX hydra: <http://www.w3.org/ns/hydra/core#>
PREFIX knossos: <https://hypermedia.app/knossos#>
</api/Article>
hydra:supportedOperation
[
hydra:method "GET" ;
code:implementedBy
[
a code:EcmaScript ;
code:link <node:@hydrofoil/labyrinth/resource#get> ;
] ;
knossos:beforeSend
[
code:implementedBy
[
a code:EcmaScript ;
code:link <file:lib/cache.js#setHeaders> ;
] ;
code:arguments
[
code:name "cache-control" ; code:value "max-age=600" ;
code:name "etag" ; code:value true ;
] ;
] ;
] ;
.
```
Here's a hypothetical implementation:

```typescript
import type { BeforeSend } from '@hydrofoil/labyrinth/middleware'
import toCanonical from 'rdf-dataset-ext/toCanonical.js'
import etag from 'etag'

type Headers = [{ etag?: boolean; 'cache-control'?: string }]

export const setHeaders: BeforeSend<Headers> = ({ res, dataset }, headers = {}) => {
if (headers['cache-control']) {
res.setHeader('cache-control', headers['cache-control'])
}

if (headers.etag) {
res.setHeader('etag', etag(toCanonical(dataset)))
}
}
```

> [!TIP]
> Resource hooks functions can be parametrised. Arguments are provided by attaching `code:arguments` to it.
> See [here](./code-arguments.md) for more details.
> For an actual implementation, see the package `@hydrofoil/creta-labs`
> [!WARNING]
> Implementors should not modify the dataset. At the time of writing this is not forbidden but may change in a future release
35 changes: 35 additions & 0 deletions docs/labs.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,38 @@ export function getPageForResource(path: string): string {
return `/app${path}`
}
```

## `cache.js`

Exports function needed to set up [caching](https://webconcepts.info/specs/IETF/RFC/7234)
and [conditional requests](https://webconcepts.info/specs/IETF/RFC/7232)

> [!TIP]
> For in-depth usage instructions see [advanced/caching](advanced/caching.md)
### `setHeaders`

A [`beforeSend` hook](./advanced/hooks.md#before-send-hooks) which sets `Cache-Control` and `eTag` headers. The latter is
calculated from [canonical serialization](https://w3c-ccg.github.io/rdf-dataset-canonicalization/spec/) of the response dataset.

Parameters:

| Parameter | Type | Required? | Default |
| -- |-- |-- | -- |
| `cache-control` | `string` | no | |
| `etag` | 'number' | no | |

### `preconditions`

A middleware which executes precondition checks, such as `if-match`. Use is as a [`resource` middleware](knossos/configuration.md#middleware)

Parameters:

| Parameter | Type | Required? | Default |
| -- |-- |-- | -- |
| `stateAsync` | `(req) => { etag?: string; lastModified?: string }` | no | Fetches `HEAD` of current `req.hydra.term` |
| `requiredWith` | 'string[]' | no | `['PUT', 'PATCH', 'DELETE']` |

Implementation of `stateAsync` must return the etag and/ore last modified date of the checked resource in their respective
lexical form as defined by the specifications.
`requiredWith` denotes which request methods will require the precondition headers.
36 changes: 36 additions & 0 deletions packages/labs/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { BeforeSend } from '@hydrofoil/labyrinth/middleware'
import type { MiddlewareFactory } from '@hydrofoil/knossos/configuration'
import { prefersMinimal } from '@hydrofoil/labyrinth/lib/request'
import toCanonical from 'rdf-dataset-ext/toCanonical.js'
import etag from 'etag'
import expressPreconditions, { Options } from 'express-preconditions'
import createError from 'http-errors'
import { fetchHead } from './lib/cache'

export type Headers = { etag?: boolean; 'cache-control'?: string }

export const setHeaders: BeforeSend<[Headers]> = ({ req, res, dataset }, headers = {}) => {
if (headers['cache-control']) {
res.setHeader('cache-control', headers['cache-control'])
}

if (headers.etag) {
const weak = !prefersMinimal(req)
res.setHeader('etag', etag(toCanonical(dataset), { weak }))
}
}

export const preconditions: MiddlewareFactory<[Pick<Options, 'stateAsync' | 'requiredWith'>]> =
async (_, { stateAsync = fetchHead(), requiredWith } = {}) => (req, res, next) => {
expressPreconditions({
stateAsync,
requiredWith,
error(status, detail) {
if (status >= 400) {
return next(createError(status, detail))
}

return res.sendStatus(status)
},
})(req, res, next)
}
26 changes: 26 additions & 0 deletions packages/labs/lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Request } from 'express'
import fetch from 'node-fetch'

export function fetchHead(_fetch = fetch) {
return async (req: Request) => {
const headers: HeadersInit = {}
if (req.headers.authorization) {
headers.Authorization = req.headers.authorization
}
if (req.headers.accept) {
headers.Accept = req.headers.accept
}
if (req.method !== 'GET') {
headers.Prefer = 'return=minimal'
}
const res = await _fetch(req.hydra.term.value, {
method: 'HEAD',
headers,
})

return {
etag: res.headers.get('etag'),
lastModified: res.headers.get('last-modified'),
}
}
}
14 changes: 13 additions & 1 deletion packages/labs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@
"*.d.ts"
],
"dependencies": {
"middleware-async": "^1.3.0"
"@hydrofoil/labyrinth": "^0.13.4",
"etag": "^1.8.1",
"express-preconditions": "^1.0.5",
"http-errors": "^2.0.0",
"middleware-async": "^1.3.0",
"node-fetch": "^2.6.7",
"rdf-dataset-ext": "^1.0.1"
},
"devDependencies": {
"@types/etag": "^1.8.1",
"@types/express-preconditions": "^1.0.0",
"@types/http-errors": "^1.8.1",
"@types/rdf-dataset-ext": "^1.0.2",
"chai": "^4.3.6",
"express": "^4.18.1",
"express-request-mock": "^3.1.0",
"mocha": "^10.0.0",
"rdf-ext": "1.3.5",
"sinon": "^14.0.0",
"supertest": "^6.2.4"
},
Expand Down
Loading

0 comments on commit ed90d77

Please sign in to comment.