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] | Key | Meaning |
|---|---|
endpoint | The operation to call. |
query | What a request may select, see List with Filters, Sorting and Paging. |
family:name | Directives 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:
| Method | Operation | Use |
|---|---|---|
GET | Observation, computation | Read one entity, a list, or a computed value. |
POST | Transition without a query, effect | Create an entity; do something. |
PUT | Transition with a query, effect | Change the entity the path selects. |
PATCH | Assignment, effect | Set some properties of the entity the path selects. |
DELETE | Transition | Remove 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
idbecomesquery.id; - any other variable becomes a condition
name==valueofquery.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.
| Reply | Response |
|---|---|
Output, to GET, PUT, PATCH, DELETE | 200 OK with the output as the body |
Output, to POST | 201 Created with the output as the body |
| Output the route does not let through | 204 No Content; 201 with no body to a POST |
Nothing: null, or no entity matched | 404 Not Found |
| A returned error | 422 Unprocessable Entity with { code, cause } as the body |
| A stream | A 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
onceis 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.