III The API

Map Methods to Operations

A client of the shop reads an order, creates one, changes some of its fields, approves it, cancels it. Each of these is an HTTP method on a route, mapped to one operation. For every request the gateway makes one call — an input and a query, as in Call Operations and Select State — and turns the reply into a response.

The orders are those of store.orders: Create a Component writes the component with its create and approve, and Expose It over HTTP its first routes, with anonymous: true on / so that anyone may call them.

§Declaring a method

The short form names the operation:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    io:output: true
    GET: enumerate
    POST: create

The long form is an object. It is needed as soon as the method has a query declaration or directives of its own:

exposition:
  /:id:
    PUT:
      endpoint: approve
      io:output: [id, status]
KeyMeaning
endpointThe operation to call.
queryWhat a request may select, see List with Filters, Sorting and Paging.
family:nameDirectives of this method.

In a component’s manifest endpoint is the name of one of its own operations; an operation that does not exist is refused when the manifest is read. In the Context an endpoint is written in full: store.orders.approve.

A method may also be served by a directive alone, without an operation — a stored file, a realtime stream, a stub. Such methods have no endpoint; they are described with their directives.

§Which operation for which method

HTTP methods carry meaning that clients, caches and crawlers act on. Map each to the kind of operation that means the same:

MethodOperationUse
GETObservation, computationRead one entity, a list, or a computed value.
POSTTransition without a query, effectCreate an entity; do something.
PUTTransition with a query, effectChange the entity the path selects.
PATCHAssignment, effectSet some properties of the entity the path selects.
DELETETransitionRemove the entity the path selects.

HEAD is served by the method declared for GET: it answers the same headers and no body. LOCK and UNLOCK may be declared like any other method where an application has a use for them. OPTIONS is not declared: it describes the resource. A method the route does not declare is 405 Method Not Allowed; one the gateway does not know at all is 501 Not Implemented.

Operation types are explained in Write Operations.

§Let a client change only some fields

A customer adds a note to an order without sending the whole order again. Map PATCH to an assignment; the inherited assign sets the properties the body carries and leaves the rest.

An order has no note yet, and the inherited assign takes no input until the manifest says which properties it may set. Add both:

# components/store.orders/manifest.toa.yaml
entity:
  properties:
    # …
    note: { type: string, maxLength: 256 }

operations:
  # …
  assign:
    input:
      type: object
      properties:
        note: { type: string, maxLength: 256 }
        items: { type: array }
      additionalProperties: false

Then add the method to the /:id route:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    # …
    /:id:
      GET: observe
      PUT: approve
      PATCH:
        endpoint: assign
        io:input: [note]
        io:output: false
$ npm run restart
PATCH /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
content-type: application/yaml

note: Leave at the door
HTTP/1.1 204 No Content

io:input keeps the route to the one property a customer may set: assign could set items too, and they cannot be patched through this route. As a scenario, written with the steps of Write a Test and run with npm run features while sys is running (npm run sys):

# features/orders.feature
  Scenario: Add a note to an order
    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:
      """
      PATCH /store/orders/${{ order.id }}/ HTTP/1.1
      content-type: application/yaml

      note: Leave at the door
      """
    Then the response is received:
      """
      204 No Content
      """
    When the request is sent:
      """
      GET /store/orders/${{ order.id }}/ HTTP/1.1
      accept: application/yaml
      """
    Then the response is received:
      """
      200 OK

      status: pending
      note: Leave at the door
      """

§The body becomes the input

The request body, decoded from the format content-type names, is the call’s input.

exposition:
  /:
    io:output: [id]
    POST: create
POST /store/orders/ HTTP/1.1
content-type: application/json

{"customer": "b70a7dbca6b14a2eaac8a9eb4b2ff4db", "items": [{"product": "kettle", "quantity": 1}], "total": 4200}

The input is validated against the operation’s input schema. A body that does not fit is refused before the operation runs:

HTTP/1.1 400 Bad Request

"must have required property 'customer'"

A route can accept less than the operation does, see io:input, and can fill input properties from the request itself — headers, the language, the host — see Pass Headers, Language and Host to an Operation.

§Route variables

For every method except POST, route variables select state. They go to the query:

  • a variable named id becomes query.id;
  • any other variable becomes a condition name==value of query.criteria.
exposition:
  /of/:customer/:id:
    io:output: true
    GET: observe

GET /store/orders/of/b70a…f4db/4c47…d6f4/ observes the order 4c47…d6f4, provided its customer is b70a…f4db. If it is another customer’s order, nothing matches and the answer is 404.

For POST there is no state to select yet, so route variables go to the input:

exposition:
  /of/:customer:
    io:output: [id]
    POST: create
POST /store/orders/of/b70a7dbca6b14a2eaac8a9eb4b2ff4db/ HTTP/1.1
content-type: application/yaml

items:
  - product: kettle
    quantity: 1
total: 4200

The operation receives customer in its input, taken from the path. A route variable overrides an input property of the same name, so a client cannot create an order for a customer other than the one in the path.

An operation that takes no query at all — a computation, an effect — receives route variables in its input too. To hand a variable to the input of any method explicitly, or to keep one out of the criteria, use map:segments.

§The query string

What a client may put after ? is decided by the method’s query declaration. With none, a method that maps a queryable operation accepts the standard parameters within default bounds:

GET /store/orders/?criteria=status==pending&sort=total:desc&limit=20 HTTP/1.1

A parameter the method does not take is refused:

HTTP/1.1 400 Bad Request

"Query must NOT have additional properties"

List with Filters, Sorting and Paging covers the parameters, their bounds, and how a route fixes or opens each of them.

§GET does not write

GET and HEAD are safe methods: a browser prefetches them, a crawler follows them, a cache replays them. A call made for a GET is read-only, and so is every call made below it: if it reaches an operation that may change state, the request fails with 500.

exposition:
  /:id:
    io:output: true
    GET: approve       # a transition: every request is answered 500

A GET mapped to an observation that calls a transition of another component fails the same way; see Loops and Read-Only Calls. An effect or an unmanaged operation counts as one that may change state, even when it only computes.

Where a safe method really has to write — a confirmation link in an email — say so with io:readonly:

exposition:
  /confirmations/:id:
    GET:
      endpoint: confirm        # a transition
      io:readonly: false

The value states what holds for the call whatever the method: false lets a GET write, true makes a POST read-only. Like any directive it can be written on a node for every method under it. HEAD follows its GET.

Use io:readonly: false as an exception. A GET that writes is still treated as safe by everything between the client and the gateway.

§What comes back

The reply of the operation decides the status.

ReplyResponse
Output, to GET, PUT, PATCH, DELETE200 OK with the output as the body
Output, to POST201 Created with the output as the body
Output the route does not let through204 No Content; 201 with no body to a POST
Nothing: null, or no entity matched404 Not Found
A returned error422 Unprocessable Entity with { code, cause } as the body
A streamA multipart response, see Stream Through the API

A returned error is the refusal the operation declares in errors: (Refuse a Request). Its code is the body the client reads:

// components/store.orders/operations/approve.ts
import type { State } from '../types/index.d.ts'

export async function transition (input: null, entry: State) {
  if (entry.status !== 'pending')
    return new Error('NOT_PENDING')

  // …
}
PUT /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
accept: application/yaml
HTTP/1.1 422 Unprocessable Entity
content-type: application/yaml

code: NOT_PENDING

An exception — a bug, an unreachable dependency — is 500 with an empty body. The full list of statuses, and what failures of the request itself look like, is in What a Client Sends and Receives.

An operation that needs to choose the status itself, for a protocol that demands it, declares io:status.

§Let a client retry safely

A client that did not receive an answer cannot tell whether its request was carried out. For a method whose operation declares once, the client repeats the request under an idempotency-key header and is answered with what the first attempt answered:

POST /store/orders/ HTTP/1.1
idempotency-key: 3f1c8a2e-5b7d-4c1e-9a55-0d6f1e2b7c33
content-type: application/yaml

customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
items: []
total: 4200
  • The key is the client’s to generate, and to repeat only for a retry of the same request.
  • The same key on two routes, or from two callers, is two calls.
  • The body is not part of the key: a retry with a different body is answered with the first answer.
  • A key sent to a method whose operation does not declare once is accepted and has no effect.
  • On a route that nobody is authenticated on, all clients share one scope of keys; give a state-changing route an access rule.

§Changing only if unchanged

A PUT or PATCH can be made conditional on the version of the entity the client last read, with the if-match header. This is covered with caching, in Prevent lost updates.

Search
Search the documentation