The W3C Basic HTTP Event I/O Processor (SCXML appendix C.2), a
Statifier.Send.Processor a host registers like any other send type
(ADR-0069, ADR-0075).
Registering it
Register it under the spec's processor URI and its short form
basichttp, with the base URL the host's own front answers at:
base = "https://example.org/scxml"
Statifier.Session.start_link(chart,
send_types: %{
"http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor" =>
{Statifier.Send.BasicHTTP, base_url: base},
"basichttp" => {Statifier.Send.BasicHTTP, base_url: base}
}
)Neither string is a built-in spelling, so the registration redirects no built-in send (ADR-0075 decision 2). The options:
:base_url(required) - the address the host's front answers at. The session's_ioprocessorscarries an entry under each registered string, both holding the same"location": this URL,/, and the session's_sessionid(C.2.3, ADR-0075 decision 3). A registration from which no entry can be built (no:base_url, or a value that is not a string) is refused when the session starts fresh, with{:error, {:send_types, {:invalid_registration, type, {:missing_option, :base_url}}}}, before any session process is spawned and so with no crash report (check_registration/2). A resumed session is not refused: its position carries the entries it started with.:transport- aStatifier.Send.BasicHTTP.Transportmodule the POSTs go through. DefaultStatifier.Send.BasicHTTP.Transport.Httpc, on OTP's:httpc; this package adds no dependency for it (ADR-0075 decision 6).
Outbound (C.2.2)
deliver/3 plans and perform/2 POSTs, the split every processor has
(Statifier.Send.Processor). The mapping (ADR-0075 decision 4):
eventbecomes the form parameter_scxmleventname, and eachnamelistentry and<param>a form parameter, in anapplication/x-www-form-urlencodedbody.- A
<content>child is the body, sent astext/plain; when the send also names anevent,_scxmleventnametravels as a query parameter of the target URL. - The two are told apart by
data's shape: a map is form-encoded,:undefinedsends_scxmleventnamealone as a form body, and any other value is the body. A<content expr>that evaluates to a map is therefore form-encoded. - A send with neither
targetnortargetexprraises C.2.2'serror.communicationon the sender's internal queue, carrying the send id, and makes no request.
A parameter value is written as text: a string as it is, a number or a
boolean as its literal, nil as null, :undefined as the empty
string. A list or a map is written as JSON text through Elixir's JSON
([1,2], {"title":"Dune"}), and :undefined inside it as JSON
null, when every value inside has a JSON form: a string that is
UTF-8, a number, a boolean, nil, :undefined, a list, or a map whose
keys are all strings. A list or a map holding anything else (an atom
key, a struct such as a Date, a tuple) keeps its inspect/1 text
whole, as does any other value that is none of these. The same rule
writes a <content> body that is a list, still sent as text/plain.
One attempt, and a miss reaches the sender. perform/2 makes one
POST. On a transport error, or a status outside 2xx, it reports the miss
through Statifier.Session.failed_send/3 to the sending session it finds
in Statifier.Registry under the plan context's session_id, so the
sender sees C.1's error.communication carrying the send id, and it
returns {:error, reason}. When no live session is registered under that
id it returns {:error, reason} only, and the dead-letter rule of
failed_send/3's documentation is the host's (ADR-0075 decision 8, point
d).
At-least-once, deduplicated by the receiver (ADR-0075's Amendment of
2026-09-30). The processor keeps no memory across perform/2 calls, so
a host that performs the same instruction twice POSTs twice. Every POST
therefore carries the send's ADR-0054 decision 3 dedup key in the
scxml-send-key header: eight fields joined by /, in the record's
order - the session scope (the plan context's session_id), the send
id, macrostep, microstep, round, c_index, owner and ordinal.
The session scope and the send id are percent-encoded (every byte outside
RFC 3986's unreserved set), the counters are decimal, and owner is
spelled onentry.S.B, onexit.S.B, transition.T or finalize.S.B
with its indexes. A receiver that deduplicates on the header sees each
send once, which is ADR-0069's idempotency MUST end to end; a receiver
that ignores it sees at-least-once delivery.
perform/2 runs in the process that performs the instruction, which for
Statifier.Session is the sending session, so a slow location holds that
session for the length of the request; the default transport bounds each
request with a timeout.
A delayed send is this processor's timer (ADR-0069 decision 4). A
<send delay> is held by a timer process perform/2 starts, and
cancel/2 plans the cancellation of every timer held under the send id
(spec 6.3). The session performing the send keeps the timer through
Statifier.Session.HaltNotice until it ends, and tells it when the
session halts. When the delay passes, the timer POSTs unless its
mailbox already holds a cancel, its session's halt notice (:done,
:cancelled or :budget_exhausted) or its session's end, so a cancel
the timer has received before the POST always wins, and a session that
has stopped, or halted, discards the send (spec 6.2). The timer never
calls the session, so a session busy at fire time does not delay or
drop the POST. A transport that raises inside the timer is a miss like
any other: it reaches the sender as error.communication through
Statifier.Session.failed_send/3, with the reason {:raised, exception}. A delayed send performed outside a Statifier.Session has
no session to hold it and is discarded.
Inbound (C.2.1)
decode/1 turns one HTTP request into a %Statifier.Event{}. It is pure
and knows no session: a front resolves the location to a session (or to
an execution, for a durable host), calls it, and enqueues the event as
an external event. The status rule a front applies (ADR-0075 decision 5):
{:ok, event}- answer 204 once the event is enqueued, before it is processed (a front that has already enqueued a request carrying the samescxml-send-keyanswers 204 again and enqueues nothing);{:error, {:method_not_allowed, method}}- answer 405 withAllow: POST;- any other
{:error, _}- answer 400; - a location that names no session the front can reach - the front's own 404.
Summary
Functions
Plans the cancellation of every delayed send this processor holds under
cancel.send_id. Pure.
Answers :ok exactly when ioprocessors_entry/2 would build an entry
from opts: when the one lookup both share finds a string :base_url
(the address the _ioprocessors location is built from, C.2.3). Any
other registration answers {:error, {:missing_option, :base_url}},
including options whose lookup raises, so this never raises. A
session's fresh start asks it and refuses a rejected registration by
name (Statifier.Send.Processor's "Refusing a registration").
Decodes one HTTP request into an external event (C.2.1, ADR-0075 decision 5). Pure.
Plans one send (see the moduledoc's "Outbound"). Pure.
The _ioprocessors entry for type: a "location" that is the
registration's :base_url, /, and the session's id (C.2.3, ADR-0075
decision 3). Raises ArgumentError when the registration carries no
:base_url; a session's fresh start refuses such a registration through
check_registration/2 before this is asked.
Types
@type decode_error() :: {:method_not_allowed, String.t()} | {:not_utf8, :query | :body} | {:malformed_send_key, String.t()}
Why decode/1 could not form an event from a request.
@type request() :: %{ :method => String.t(), :content_type => String.t() | nil, :body => binary(), :query => String.t() | nil, optional(:send_key) => String.t() | nil }
What decode/1 is handed: the request's method, its content type (nil
when the request carries none), its body, its query string (nil when
the URL has none), and optionally the value of its scxml-send-key
header (nil or absent when the request carries none).
Functions
@spec cancel( cancel :: Statifier.Effect.Cancel.t(), ctx :: Statifier.Send.Processor.ctx() ) :: {:ok, [Statifier.Send.Processor.instruction()]}
Plans the cancellation of every delayed send this processor holds under
cancel.send_id. Pure.
@spec check_registration(type :: String.t(), opts :: keyword()) :: :ok | {:error, {:missing_option, :base_url}}
Answers :ok exactly when ioprocessors_entry/2 would build an entry
from opts: when the one lookup both share finds a string :base_url
(the address the _ioprocessors location is built from, C.2.3). Any
other registration answers {:error, {:missing_option, :base_url}},
including options whose lookup raises, so this never raises. A
session's fresh start asks it and refuses a rejected registration by
name (Statifier.Send.Processor's "Refusing a registration").
@spec decode(request :: request()) :: {:ok, Statifier.Event.t()} | {:error, decode_error()}
Decodes one HTTP request into an external event (C.2.1, ADR-0075 decision 5). Pure.
- The method must be POST; any other is
{:error, {:method_not_allowed, method}}. - The event name is the first
_scxmleventnamefound, the query string before a form body, elseHTTP.and the method in upper case (HTTP.POST). - A form body's other parameters, with the query string's, become
_event.data, each value throughStatifier.EventData's text rung (a predicator literal, else the string), so2reads as the number 2. A body of any other content type becomes_event.datathrough the same text rung, and the query string then contributes the event name only. origintypeis the processor URI.:send_key, thescxml-send-keyheader's value, sets no event field: the front deduplicates on the header's value itself, and the event'ssendidstays unset. A value that is not the header's eight fields is{:error, {:malformed_send_key, value}}.
A query string or a body that is not UTF-8 once decoded forms no
datamodel string, and is {:error, {:not_utf8, :query | :body}}.
A JSON body (application/json) is a body of another content type: it
goes through the text rung like any other, so a JSON object or array
that is also a predicator literal reads as a map or a list, and anything
else stays a string. No charset is read: a body that is not UTF-8 is
{:error, {:not_utf8, :body}} whatever charset its content type names.
@spec deliver( send :: Statifier.Effect.Send.t() | Statifier.Effect.SendDelayed.t(), event :: Statifier.Event.t(), ctx :: Statifier.Send.Processor.ctx() ) :: {:ok, [Statifier.Send.Processor.instruction()]}
Plans one send (see the moduledoc's "Outbound"). Pure.
@spec ioprocessors_entry( type :: String.t(), context :: Statifier.Send.Processor.entry_context() ) :: map()
The _ioprocessors entry for type: a "location" that is the
registration's :base_url, /, and the session's id (C.2.3, ADR-0075
decision 3). Raises ArgumentError when the registration carries no
:base_url; a session's fresh start refuses such a registration through
check_registration/2 before this is asked.
@spec perform(payload :: term(), ctx :: Statifier.Send.Processor.ctx()) :: :ok | {:error, term()}
Performs one instruction deliver/3 or cancel/2 planned: a POST, a
delayed POST's timer, or the cancellation of the timers held under a
send id (see the moduledoc's "Outbound").