Current section
Files
Jump to
Current section
Files
docs/basichttp.md
# The Basic HTTP Event I/O Processor
SCXML appendix C.2 defines the Basic HTTP Event I/O Processor: a `<send>`
whose `type` names it is delivered as an HTTP POST, and a session that runs
it accepts POSTs at an address of its own and raises each one as an
external event. Statifier ships it as `Statifier.Send.BasicHTTP`, a
`Statifier.Send.Processor` a host registers per session like any other send
type (see "The `<send>` half" in [Extending Statifier](extending.md)). A
session that does not register it sees none of this page: no request is
made, nothing is started, and `_ioprocessors` holds the SCXML processor's
entry alone.
The decisions behind the processor are
[ADR-0075](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0075-basichttp-event-io-processor.md)
and its Amendment of 2026-09-30, which adds the `scxml-send-key` header
below.
## Registering it
Register the processor under both of its type strings, the spec's URI and
the short form `basichttp`, with the base URL your own front answers at:
```elixir
base = "https://example.org/scxml"
registration = {Statifier.Send.BasicHTTP, base_url: base}
Statifier.Session.start_link(chart,
send_types: %{
"http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor" => registration,
"basichttp" => registration
}
)
```
A `:send_types` value is a bare module or `{module, opts}`; this processor
needs the options form. Its options:
- `:base_url` (required) - the address your front answers at. A
registration without it is refused when the session starts, with an
`ArgumentError` naming the option.
- `:transport` - the module the POSTs go through, a
`Statifier.Send.BasicHTTP.Transport`. The default is
`Statifier.Send.BasicHTTP.Transport.Httpc`, on OTP's `:httpc`, which
verifies TLS peers against the system CA store and bounds each request
with a timeout. The package adds no dependency for it; the last section
below shows an adapter on `req`. When `:ssl` or `:public_key` cannot be
loaded, the default adapter answers
`{:error, {:not_loadable, module, reason}}` instead of making the
request, and the processor reports the miss to the sender like any
other failed delivery.
Neither type string is a built-in spelling, so the registration redirects
no built-in send.
## The location
C.2.3 asks for an `_ioprocessors` entry holding a `location` that external
entities can use to reach the session. The session carries one entry under
each registered string, and both hold the same location: the base URL, a
`/`, and the session's `_sessionid`.
```xml
<send type="http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor"
targetexpr="_ioprocessors['basichttp']['location']"
event="ping"/>
```
The entries are written once, when the session starts, and persist with
the datamodel, so a resumed session reads the location it started with.
## The front you write around `decode/1`
The library receives nothing on its own. Your host runs the HTTP endpoint
at the base URL, and for each request it:
1. resolves the path after the base URL to a session id, and that id to a
running session;
2. hands the request to `Statifier.Send.BasicHTTP.decode/1`, a pure
function that knows no session;
3. unless it has already enqueued a request carrying the same
`scxml-send-key` header value (next section), enqueues the event the
decoder answers as an external event, for example with
`Statifier.Session.send_event/2`;
4. answers by the status rule below.
`decode/1` takes a map of the request's `:method`, `:content_type` (`nil`
when absent), `:body` and `:query` (`nil` when the URL has none), and
optionally `:send_key`, the `scxml-send-key` header's value. It answers
`{:ok, event}` or `{:error, reason}`. The status rule:
| `decode/1` answers | The front answers |
|---|---|
| `{:ok, event}` | 204, once the event is enqueued and before it is processed; 204 again, with nothing enqueued, for a `scxml-send-key` value the front has already enqueued |
| `{:error, {:method_not_allowed, method}}` | 405, with `Allow: POST` |
| `{:error, {:malformed_send_key, value}}` | 400 |
| any other `{:error, _}` | 400 |
| (the path names no session the front can reach) | 404 |
A front on Plug might read (text, not compiled; Plug is not a dependency of
this package):
```elixir
def call(%Plug.Conn{path_info: [session_id]} = conn, _opts) do
{:ok, body, conn} = Plug.Conn.read_body(conn)
request = %{
method: conn.method,
content_type: conn |> Plug.Conn.get_req_header("content-type") |> List.first(),
body: body,
query: if(conn.query_string == "", do: nil, else: conn.query_string),
send_key: conn |> Plug.Conn.get_req_header("scxml-send-key") |> List.first()
}
with {:ok, pid} <- MyApp.Sessions.whereis(session_id),
{:ok, event} <- Statifier.Send.BasicHTTP.decode(request) do
# MyApp.Delivered records each key once and answers whether it was new.
if MyApp.Delivered.first?(session_id, request.send_key),
do: :ok = Statifier.Session.send_event(pid, event)
Plug.Conn.send_resp(conn, 204, "")
else
:no_session -> Plug.Conn.send_resp(conn, 404, "")
{:error, {:method_not_allowed, _method}} ->
conn |> Plug.Conn.put_resp_header("allow", "POST") |> Plug.Conn.send_resp(405, "")
{:error, _reason} -> Plug.Conn.send_resp(conn, 400, "")
end
end
```
This repository's own loopback front, which its conformance runs deliver
through, is `Mix.Statifier.BasicHTTPFront`: repository tooling on OTP's
`:inets` httpd, not part of the package, and a small model of the steps
above. It lives only as long as one test run and does not deduplicate.
## At-least-once delivery and the `scxml-send-key` header
The processor makes one POST each time an instruction is performed, and
keeps no memory between performs. A host that performs the same
instruction twice - after a crash and a retry, for example - POSTs
twice, so delivery is at-least-once. To let the receiver deliver each
send once, every POST the processor makes, immediate or delayed and
whatever its body, carries the send's deduplication key in a request
header:
- **Name:** `scxml-send-key`.
- **Value:** eight fields joined by `/`: the session scope (the
sender's `_sessionid` for a live session), the send id, `macrostep`,
`microstep`, `round`, `c_index`, `owner` and `ordinal`. The session
scope and the send id are percent-encoded, so neither carries a `/`;
the counters are decimal integers; `owner` is spelled `onentry.S.B`,
`onexit.S.B`, `finalize.S.B` or `transition.T` with its indexes. A
field the send does not carry is the empty string.
Every field is a deterministic counter or a static position, so a
re-performed send carries a byte-identical value. A front that enqueues
a request only when it has not already enqueued one with the same value
delivers each send once; a front that ignores the header sees
at-least-once delivery. The decoder does not deduplicate: it is pure and
remembers nothing, so the front keeps the record of the values it has
enqueued, and answers a repeat 204 with nothing enqueued.
`decode/1` only checks the value's shape. It sets no field of the event
from it, and a value that is not eight `/`-separated fields whose second
field percent-decodes to UTF-8 is
`{:error, {:malformed_send_key, value}}`, which the front answers 400. A
request without the header decodes as before.
## The mapping, both ways
Outbound, a `<send>` to the processor becomes one POST (C.2.2):
| The `<send>` carries | The request carries |
|---|---|
| `event` | the form parameter `_scxmleventname` |
| `namelist` entries and `<param>` children | one form parameter each, in an `application/x-www-form-urlencoded` body; a repeated name keeps the last value |
| a `<content>` child | the content as the body, sent as `text/plain`; with an `event` too, `_scxmleventname` travels as a query parameter of the target URL |
| no parameters and no content | `_scxmleventname` alone, as a form body |
| a `<content expr>` that evaluates to a map | a form-encoded body, as parameters would be |
| neither `target` nor `targetexpr` | no request: `error.communication` on the sender's internal queue, carrying the send id |
A parameter value is written as text: a string as it is, a number or a
boolean as its literal, `nil` as `null`, and an undefined value as the
empty string. The processor makes one attempt. A transport error, or a
status outside 2xx, reaches the sender as `error.communication` carrying
the send id, through `Statifier.Session.failed_send/3`. Every request also
carries the `scxml-send-key` header above. A `<send delay>` is
held by the processor's own timer, and a `<cancel>` naming the send cancels
it while it has not fired.
Inbound, `decode/1` turns one request into one event (C.2.1):
| The request carries | The event carries |
|---|---|
| one or more `_scxmleventname` parameters | the first as its name, the query string read before the body |
| no `_scxmleventname` | `HTTP.` and the method in upper case as its name (`HTTP.POST`) |
| a form body | every other parameter, query string included, in `_event.data` |
| a body of any other content type | the body as `_event.data`; the query string gives the name only |
| (always) | the processor URI as `origintype` |
Each value is read as a `<content>` body's text is: a predicator literal
becomes that value, so `2` reads as the number 2, and anything else stays
a string.
## What is not supported
- JSON bodies and charset handling inbound: a non-form body is read as
text.
- A parameter value that is a list or a map outbound: its encoding is not
decided; today it is written with `inspect/1`.
- `_event.origin` on an inbound event: the decoder has no address a reply
could be sent to.
- A location after a resume on a host whose base URL moved: the persisted
`_ioprocessors` keeps the location written when the session started.
- Authentication of inbound POSTs: C.2 defines none, and a front that
needs it adds it itself.
- A session that is persisted and not running: the location reaches a
live registered session only. A durable host's front resolves the
location to its own execution.
## In the conformance corpus
The W3C documents that need the processor are in the corpus, each with a
`host` object whose `event_io_processors` names the processor's URI; a
runner registers it under both type strings and delivers through a
loopback front (`conformance/RATCHET.md`, "The `host` object"). Eleven of
them are claimed. `test201` is in the corpus and unclaimed: it expects an
event sent through the processor to arrive ahead of a `<send>` the same
step appends to the session's own external queue, and a delivery from
outside the session never can.
## A transport on `req`
A host that already uses `req` writes an adapter of its own and passes it
as `:transport`. As text, not compiled; this package does not depend on
`req`:
```elixir
defmodule MyApp.ReqTransport do
@behaviour Statifier.Send.BasicHTTP.Transport
@impl true
def post(url, headers, body) do
case Req.post(url, headers: headers, body: body, retry: false) do
{:ok, %Req.Response{status: status}} -> {:ok, status}
{:error, exception} -> {:error, exception}
end
end
end
```
`retry: false` keeps the one attempt the processor makes: retrying is not
the transport's job, and a status outside 2xx is answered as
`{:ok, status}`, not as an error, so the processor reports it as a miss.
```elixir
registration =
{Statifier.Send.BasicHTTP, base_url: base, transport: MyApp.ReqTransport}
```