III The API

Choose What a Route Accepts and Returns

An order holds properties a customer must not set, such as status, and may hold ones a customer must not see. A route is usually narrower than the operation behind it. The io directives state what a route lets out of a reply, what it accepts in a body, which status a reply carries, and how often it may be called.

The orders are those of store.orders: Create a Component writes the component with its create and approve, and Expose It over HTTP its routes, with io:output: true and anonymous: true on /. The fragments here show the io directives only; a route still needs its access rule.

§Choose what comes back

A method returns nothing unless it says what it returns. Without io:output the operation runs and its reply is dropped:

# components/store.orders/manifest.toa.yaml
exposition:
  /:id:
    GET: observe
GET /store/orders/4c4759e6f9c74da989d64511df42d6f4/ HTTP/1.1
HTTP/1.1 204 No Content

So a property added to an entity later never leaks through a route written before it existed.

§Naming the properties

io:output lists the properties of the reply the client receives:

exposition:
  /:id:
    io:output: [id, status, total]
    GET: observe
HTTP/1.1 200 OK
content-type: application/yaml

id: 4c4759e6f9c74da989d64511df42d6f4
status: pending
total: 4200

The list applies to each object of the reply, whatever its shape: the object itself, each object of an array, each object a stream yields. A value that is not an object — a number, a string — is returned as it is.

System properties are properties like any other. A route whose clients need the version or the creation time lists them: io:output: [id, status, VERSION, CREATED].

§Returning everything

true returns the reply whole:

# components/store.catalog/manifest.toa.yaml
exposition:
  /quote:
    GET:
      endpoint: quote          # a computation that answers a number
      io:output: true

Use it where the operation does not return an object with known properties: a primitive, a stream of bytes, an object whose keys are data. For entities, prefer a list.

§Returning nothing

false says the same as declaring nothing, on purpose. cancel here is a transition that sets the order’s status to cancelled, as in Write Operations:

# components/store.orders/manifest.toa.yaml
exposition:
  /:id:
    DELETE:
      endpoint: cancel
      io:output: false

A method with no output takes the status an empty body takes: 204, 201 for a POST, and 404 where the operation found nothing. A stream the operation answers is closed unread.

§Output on a node and on a method

io:output on a node applies to every method below it. Where a method states one too, the client receives only what both permit:

exposition:
  /:
    io:output: [id, status, total]
    /:id:
      GET:
        io:output: [status, total, items]
        endpoint: observe

GET /store/orders/:id/ returns status and total. items is not in the node’s list, so the method cannot add it. A node’s list is therefore the ceiling for everything under it; to return more on one route, declare the route outside the node or widen the node’s list.

§What is not restricted

A returned error. The body of a 422 is the code and the cause of the error the operation declared, not its output, and no list applies to it.

§Choose what a client may send

By default a route accepts whatever the operation’s input schema accepts. io:input lists the properties a client may send:

# components/store.orders/manifest.toa.yaml
exposition:
  /:
    anonymous: true
    io:input: [customer, items, total]
    io:output: [id]
    POST: create
POST /store/orders/ HTTP/1.1
content-type: application/yaml
accept: text/plain

customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
items:
  - product: kettle
    quantity: 1
total: 4200
status: approved
HTTP/1.1 400 Bad Request

Unexpected input: status

Use it where an operation’s input holds properties that are not the client’s to set. The same operation can then be exposed twice, to a customer with a narrow input and to staff with a wider one.

  • The list must be a subset of the operation’s input properties.
  • With io:input declared, a body that is not an object is refused with 400. It therefore fits operations whose input is an object or an array of objects.
  • io:input: [] accepts no body properties at all. Properties the route fills itself with map directives are not affected: they are the route’s, not the client’s.

Both directives in one scenario. Its first two steps are among those the application came with (Write a Test); the third is one more, for a line a response must not have:

// features/steps/http.ts
Then('the response does not contain:', function (this: World, text: string) {
  this.agent.responseExcludes(text)
})
# features/orders.feature
  Scenario: A customer cannot set the status and receives only the id
    When the request is sent:
      """
      POST /store/orders/ HTTP/1.1
      content-type: application/yaml
      accept: application/yaml

      customer: b70a7dbca6b14a2eaac8a9eb4b2ff4db
      items: []
      total: 4200
      status: approved
      """
    Then the response is received:
      """
      400 Bad Request
      """
    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 }}
      """
    And the response does not contain:
      """
      status:
      """
$ npm run sys
$ npm run env
$ npm run features

§Choose the status

The status of a response follows from the reply: 200, 201 for a POST, 422 for a returned error. Some protocols need more — an endpoint that must answer 400 with a structured body of the protocol’s own, or 200 where a POST would say 201.

io:status names a property of the output that carries the status. The property is removed from the body:

# components/payments.webhooks/manifest.toa.yaml
exposition:
  /callbacks:
    POST:
      endpoint: accept
      io:status: status
      io:output: [status, received, error]
// components/payments.webhooks/operations/accept.ts
import type { AcceptInput, Context } from '../types/index.d.ts'

export async function effect (input: AcceptInput, context: Context) {
  const order = await context.remote.store.orders.observe({ query: { id: input.order } })

  if (order === null) return { status: 400, error: 'unknown_order' }

  return { status: 200, received: true }
}
HTTP/1.1 400 Bad Request
content-type: application/yaml

error: unknown_order

payments.webhooks is the component that receives what a payment provider sends, written in Receiving Webhooks; accept here is an operation of it for a provider whose protocol expects 200 or 400 with a body of its own.

  • The method lists the status property in io:output: the directive reads it from what the route lets out.
  • A reply without the property is answered with the status it would have had.
  • A property that is not a number is an exception.
  • An operation that uses io:status for failures returns them as output, not as errors: return { status: 400, error: 'invalid_redirect_uri' }. io:output then lists the properties of both shapes.

Reach for it only when a protocol dictates the status. For an application’s own refusals, return an error and let the client read 422 and the code.

§Limit how often a route is called

io:throttle limits how often a route is called:

exposition:
  /:
    io:throttle:
      key: ip
      requests: 60
      interval: 60
    POST: create

Requests are metered per key. A key may spend requests at once and earns them back at requests per interval seconds. A request whose key has nothing left is refused:

HTTP/1.1 429 Too Many Requests
retry-after: 1

retry-after is the number of seconds until one request would be admitted again. There is no lockout: a client that has overspent is admitted as soon as it has earned one request back, and asking in the meantime costs it nothing.

So requests is the burst, and requests / interval is the sustained rate. The example allows sixty orders at once from one address, and one a second after that.

§Keys

key is one of the following, or a list of them. A list meters the combination: [route, ip] gives each address its own budget on each route.

KeyMetered against
ipThe client address.
pathThe path of the request: /store/orders/4c47…d6f4/.
routeThe route as declared: /store/orders/:id. All paths of the route share a budget.
identityThe authenticated identity. Requests without one share a single budget.
segment: <name>The value of a route variable.
io:throttle:
  key:
    - route
    - identity
  requests: 10
  interval: 60

Choose route over path for routes with variables: keyed on path, walking through identifiers is a way around the limit.

Pair identity with ip where anonymous requests reach the route, or they all spend one budget.

ip needs the gateway to know the client’s address, which behind a load balancer it reads from a header the Context names; see Client address. A request with no known address is not throttled by ip.

§Charging only for some responses

A condition meters only the requests whose response matches. A request that does not match is served and costs nothing:

exposition:
  /:
    io:throttle:
      key: ip
      condition:
        status: 404
      requests: 20
      interval: 600

An address may probe for twenty missing orders at once and one every thirty seconds thereafter. Requests that find something are never counted.

A condition is checked against the response, so it decides what is charged, not what is refused: the request that exhausts a budget is still served, and the next one is refused. status is the only condition.

§How exact it is

The budget is for the whole application, not per gateway replica, and it is approximate: a burst spread over several replicas can overshoot it briefly. Use throttling to stop abuse and runaway clients, not to enforce an exact quota.

§Reference

KeyTypeMeaning
keykey or list of keysWhat the budget is kept for. Required.
requestsinteger ≥ 1The burst. Required.
intervalinteger ≥ 1Seconds in which requests are earned back. Required.
condition{ status } or a list of themCharge only matching responses.

§Read-only calls

io:readonly states whether the call a method makes may change state. It exists for the rare GET that has to write and is described in Map Methods to Operations.

§What clients are told

The io directives shape what a route says about itself. In the answer to OPTIONS, the input schema is the operation’s restricted by io:input, the output schema is restricted by io:output, and a method without io:output states no output at all.

§Summary

DirectiveValueDefault
io:outputlist of properties, true or falsefalse: no body
io:inputlist of propertieseverything the operation takes
io:statusname of an output propertystatus follows from the reply
io:readonlybooleantrue for GET and HEAD, false otherwise
io:throttle{ key, requests, interval, condition }not throttled
Search
Search the documentation