III The API

Cache Replies and Prevent Lost Updates

The catalogue changes rarely, and browsers should not download it on every visit. Two members of staff edit one order, and the second save must not silently undo the first. Three things serve these needs: cache-control tells clients how long a reply may be kept, etag and if-none-match let a client ask “has this changed?” without downloading it again, and VERSION with if-match lets a client write only if nothing changed since it read.

§Declaring how long a reply is kept

cache:control sets the cache-control header of successful replies to GET and HEAD. The catalogue is a component of its own, store.catalog, whose products have a title and a price:

# components/store.catalog/manifest.toa.yaml
exposition:
  /:
    anonymous: true
    io:output: [id, title, price]
    cache:control: max-age=300
    GET: enumerate
HTTP/1.1 200 OK
cache-control: max-age=300

The value is a standard cache-control value; the gateway does not interpret max-age or keep replies itself. It tells browsers and whatever stands in front of the application what they may do.

Like any directive it applies to everything under the node it is written on, and a nested declaration replaces it:

exposition:
  /:
    cache:control: max-age=300
    GET: enumerate
    /:id:
      cache:control: max-age=30
      GET: observe

Replies to other methods are not affected: a POST on a node with cache:control gets no cache-control from it.

§What happens without a declaration

  • A reply to an anonymous request carries no cache-control.
  • A reply to an authenticated request carries cache-control: no-store.

So nothing private is stored anywhere unless a route says it may be.

§Authenticated requests

A reply to an authenticated request may hold private data. cache:control therefore adjusts its value when the request is authenticated:

DeclaredSent to an authenticated requestWhy
no privateprivate is addedA shared cache must not store one identity’s reply.
private, declared or addedvary: authorization is addedA browser must not reuse the reply after signing in as someone else.
public without no-cacheno-cache is addedA shared cache must revalidate, so that tokens are not served from it.
# components/store.orders/manifest.toa.yaml
exposition:
  /of/:customer:
    auth:id: customer
    io:output: [id, status, total]
    cache:control: max-age=60
    GET: enumerate

The orders are those of store.orders, the component Create a Component writes, and auth:id admits the customer the path names.

HTTP/1.1 200 OK
cache-control: private, max-age=60
vary: authorization

The same route answered to an anonymous request, where its access rule admits one, carries the value as declared: cache-control: max-age=60, without vary: authorization.

Two consequences to keep in mind:

  • Because a private reply varies on authorization, a browser’s copy is dropped whenever the client’s token is replaced. The useful max-age of a private resource is at most the lifetime of a token.
  • A reply that itself issues a token carries no-store, whatever the route declares.

Tokens and their lifetime are described in Tokens.

§An exact value

cache:exact sets cache-control to exactly what is written, with none of the adjustments above, and for any method. Here the route is one of a component that keeps the shop’s banners, open to staff:

exposition:
  /banners:
    auth:role: staff
    io:output: true
    cache:exact: public, max-age=3600
    GET: enumerate
HTTP/1.1 200 OK
cache-control: public, max-age=3600

With cache:exact the safety is yours. Use it for replies that are the same for every caller although the route requires a credential.

Its other use is to forbid storing the reply of a method that is not GET, such as that of an operation issue which answers a receipt:

exposition:
  /receipts:
    cache:exact: no-store
    POST: issue
cache:controlcache:exact
MethodsGET, HEADany
Adjusted for authenticated requestsyesno

§Asking whether a reply changed

Every reply to a GET or HEAD carries an etag: a hash of the body the client receives.

GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
HTTP/1.1 200 OK
etag: "9b2cf535f27731c974343645a3985328"

{"id":"4c4759e6f9c74da989d64511df42d6f4","status":"pending","total":4200}

A client that kept the reply sends the tag back in if-none-match. If the body would be the same, it gets no body:

GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
if-none-match: "9b2cf535f27731c974343645a3985328"
HTTP/1.1 304 Not Modified
etag: "9b2cf535f27731c974343645a3985328"

Nothing is declared for this. Browsers do it on their own for replies they have stored.

  • The tag is of the body as this client receives it: after io:output, in the negotiated format. Two routes over one entity have different tags.
  • A weak tag (W/"…") in if-none-match matches as a strong one does.
  • HEAD returns the tag its GET would, without the body.
  • A 304 saves the transfer, not the work: the operation runs to produce the body the tag is computed from.

A reply carries no etag when a client could never send it back:

  • a reply to a method other than GET and HEAD;
  • a reply declared no-store;
  • a reply that is not the operation’s — an error, an exception.

A stored file’s tag is the checksum of its content, see Upload and Download Files.

§Prevent lost updates

Two members of staff open the same order. Both edit it. Without a precaution the second save silently undoes the first.

Every entity carries a VERSION that grows with each change. A client that sends the version it read in the if-match header has its write applied only if that is still the current version.

VERSION is one of the system properties of the orders store.orders keeps. The write here is amend, an operation that replaces the items of an order; it forwards to the inherited transit:

# components/store.orders/manifest.toa.yaml
operations:
  # …
  amend:
    forward: transit
    concurrency: retry
    query: true
    input:
      type: object
      properties:
        items: { type: array }
      required: [items]

Expose the version to clients, on the /:id route Expose It over HTTP declares:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    anonymous: true
    io:output: true
    GET: enumerate
    POST: create
    /:id:
      io:output: [id, items, total, status, VERSION]
      GET: observe
      PUT: amend
$ npm run restart
GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
accept: application/yaml
HTTP/1.1 200 OK

id: 4c4759e6f9c74da989d64511df42d6f4
status: pending
total: 4200
VERSION: 3

The client writes with the version it holds, in quotes:

PUT /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
if-match: "3"
content-type: application/yaml

items:
  - product: kettle
    quantity: 2
HTTP/1.1 200 OK

If the order has changed in the meantime, the write is refused and nothing is changed:

HTTP/1.1 412 Precondition Failed

The client reads the order again, shows the user what changed, and retries with the new version.

The value in if-match becomes query.version of the call (Call Operations and Select State); the operation does nothing for it.

if-match takes the VERSION, not the etag. The etag is a hash of a representation and says nothing about the entity’s version. Sending it in if-match is refused:

HTTP/1.1 400 Bad Request

"Invalid ETag"

A weak form, if-match: W/"3", is accepted and means the same.

As a scenario, with a stale version; its steps are those of Write a Test, and it is run with npm run features while sys is running (npm run sys):

# features/orders.feature
  Scenario: A write with a stale version is refused
    When the request is sent:
      """
      POST /store/orders/ HTTP/1.1
      content-type: application/yaml
      accept: application/yaml

      customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
      items: []
      total: 4200
      """
    Then the response is received:
      """
      201 Created

      id: ${{ order.id }}
      """
    When the request is sent:
      """
      GET /store/orders/${{ order.id }}/ HTTP/1.1
      accept: application/yaml
      """
    Then the response is received:
      """
      200 OK

      VERSION: ${{ order.version }}
      """
    When the request is sent:
      """
      PUT /store/orders/${{ order.id }}/ HTTP/1.1
      if-match: "${{ order.version }}"
      content-type: application/yaml

      items:
        - product: kettle
          quantity: 2
      """
    Then the response is received:
      """
      200 OK
      """
    When the request is sent:
      """
      PUT /store/orders/${{ order.id }}/ HTTP/1.1
      if-match: "${{ order.version }}"
      content-type: application/yaml

      items:
        - product: kettle
          quantity: 3
      """
    Then the response is received:
      """
      412 Precondition Failed
      """

§Making it mandatory

if-match is optional unless the route requires it. To make every write conditional:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    # …
    /:id:
      io:output: [id, items, total, status, VERSION]
      GET: observe
      PUT:
        endpoint: amend
        require:header: if-match

A PUT without the header is now 400, see Requiring headers.

§Where it does not apply

if-match is a version only for an operation that takes a query. On a route to a computation or an effect it is an ordinary header, which the route may read with map:headers if it has a use for it.

§A checklist

  • Public, rarely changing data: anonymous access and cache:control: max-age=….
  • Private data worth keeping in the browser: cache:control: max-age=…; it is made private for you.
  • Identical for every signed-in user: cache:exact: public, max-age=….
  • Must never be stored: cache:exact: no-store.
  • Clients that poll: nothing to declare; they send if-none-match.
  • Entities that several people edit: VERSION in io:output, and require:header: if-match.
Search
Search the documentation