Statifier.Send.BasicHTTP (Statifier v2.12.1)

Copy Markdown View Source

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 _ioprocessors carries 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 - a Statifier.Send.BasicHTTP.Transport module the POSTs go through. Default Statifier.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):

  • event becomes the form parameter _scxmleventname, and each namelist entry and <param> a form parameter, in an application/x-www-form-urlencoded body.
  • A <content> child is the body, sent as text/plain; when the send also names an event, _scxmleventname travels as a query parameter of the target URL.
  • The two are told apart by data's shape: a map that is not a struct is form-encoded, :undefined sends _scxmleventname alone as a form body, and any other value is the body. A <content expr> that evaluates to a map is therefore form-encoded, and one that evaluates to a struct (a Date, for example) is the body, its inspect/1 text sent as text/plain.
  • A send with neither target nor targetexpr raises C.2.2's error.communication on 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 same scxml-send-key answers 204 again and enqueues nothing);
  • {:error, {:method_not_allowed, method}} - answer 405 with Allow: POST;
  • any other {:error, _} - answer 400;
  • a location that names no session the front can reach - the front's own 404.

Summary

Types

Why decode/1 could not form an event from a request.

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

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. That one reason covers a :base_url that is absent and one that is present but not a string (an atom, an integer, a charlist): no separate reason names a malformed value. 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.

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").

Types

decode_error()

@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.

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

cancel(cancel, ctx)

Plans the cancellation of every delayed send this processor holds under cancel.send_id. Pure.

check_registration(type, opts)

@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. That one reason covers a :base_url that is absent and one that is present but not a string (an atom, an integer, a charlist): no separate reason names a malformed value. A session's fresh start asks it and refuses a rejected registration by name (Statifier.Send.Processor's "Refusing a registration").

decode(request)

@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 _scxmleventname found, the query string before a form body, else HTTP. and the method in upper case (HTTP.POST).
  • A form body's other parameters, with the query string's, become _event.data, each value through Statifier.EventData's text rung (a predicator literal, else the string), so 2 reads as the number 2. A body of any other content type becomes _event.data through the same text rung, and the query string then contributes the event name only.
  • origintype is the processor URI.
  • :send_key, the scxml-send-key header's value, sets no event field: the front deduplicates on the header's value itself, and the event's sendid stays 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.

deliver(send, event, ctx)

Plans one send (see the moduledoc's "Outbound"). Pure.

ioprocessors_entry(type, map)

@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.

perform(arg, ctx)

@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").