The webhook front: a Plug-shaped helper that turns one already-verified
webhook request into a routing attempt. This package verifies nothing.
The host authenticates the request - typically a provider's signature over
the raw body - and calls handle/3 only after that verification has
passed. A request that reaches this module is one the host has already
vouched for.
It is Plug-shaped, not a Plug: it adds no dependency on Plug or Phoenix,
and starts no process. The host writes the controller action or the plug
and calls handle/3 from it; step 2 of the guide "How to take webhooks
and form posts"
shows such an action.
The request
handle/3 takes a plain map the host builds from the connection:
:scope- the host's routing scope, a string, as it rides with every event (ADR-0003, section 8);:source- the source the bindings name;:raw_body- the request body exactly as it arrived, the bytes whose signature was verified; it may be left out when:provider_idis a non-empty string (below);:data- the adapter-normalized event, a string-keyed map;:provider_id- the provider's own event id, ornil;:selector- the source's selector, carried for the host's own front and never read here. A binding'sselectoris the source adapter's to read, and the router never reads it (ADR-0001, section 1); bindings are chosen by source alone.
Any other key is ignored. A request missing :scope, :source or
:data, or carrying one of those, or :raw_body, of the wrong type, is
{:error, {:invalid_request, request}} and nothing is routed.
:raw_body is required unless :provider_id is a non-empty string. A
front that hands over an id of its own as :provider_id - the id of a
row in which it stored the post, say - has no bytes to hash and may
leave :raw_body out; the provider id is then the message id. A request
with neither a binary :raw_body nor a non-empty string :provider_id
is {:error, {:invalid_request, request}}. Only the absent key counts
as no body: raw_body: nil, or any other non-binary value, is the wrong
type and is refused whatever :provider_id says, so a request that
carries :raw_body is answered exactly as before the key became
optional.
The message id
The message id is the dedupe key downstream, so which source wins is fixed here rather than left to each host (ADR-0003, section 6):
- the provider's
:provider_idwins whenever it is a non-empty string; - otherwise -
nil,"", or any non-string - the message id is the lowercase hex SHA-256 of:raw_body.
A provider that sends no event id therefore gets one delivery per
distinct body, and its retry of the same body is the same message. There
is no third source: a request must carry a non-empty string
:provider_id or a binary :raw_body, so an id is always derivable,
and the SHA-256 of an empty body is a well-defined, constant id rather
than a failure.
A body-hash id is scope-free. The dedupe key is the binding and the
message id (StatifierRouter.Dedupe.claim/4), and a binding carries no
scope, so the same body under two scopes through one shared binding is
one message: the second is a duplicate, and nothing reaches the second
scope's execution. A host that routes several scopes through one front
therefore either gives each scope its own binding or uses a provider that
sends an event id.
The answer and the status
handle/3 answers exactly as StatifierRouter.route/3 does,
{:ok, outcomes} or {:error, reason}, and status/1 maps that answer
to the HTTP status the provider should see.
Summary
Types
@type answer() :: {:ok, [StatifierRouter.outcome()]} | {:error, term()}
@type request() :: %{ :scope => String.t(), :source => String.t(), optional(:raw_body) => binary(), :data => map(), optional(:provider_id) => String.t() | nil, optional(:selector) => map(), optional(atom()) => term() }
The request a host hands handle/3 once it has verified the signature.
The module documentation describes each key.
Functions
@spec handle(StatifierRouter.Config.t(), request(), keyword()) :: answer()
Routes one verified webhook request.
Derives the message id as the module documentation describes, builds the
source event StatifierRouter.route/3 takes and returns that function's
answer unchanged: {:ok, outcomes}, one outcome per enabled binding
whose source is the request's, or {:error, reason}.
Returns {:error, {:invalid_request, request}}, before any binding is
evaluated, for a request this module cannot build an event from.
opts are StatifierRouter.route/3's and are passed through unchanged,
so an unknown key is that function's error.
@spec status(answer()) :: 200 | 500
The HTTP status the provider should see for one handle/3 answer.
{:ok, outcomes} is 200, whatever those outcomes are. Every one of
them is a recorded answer this router will give again for the same
message - a duplicate, a drop, a refusal and a no-match included - so a
retry would change nothing and the provider should stop.
{:error, reason} is 500: the attempt did not settle, so the provider
should retry.
iex> StatifierRouter.Webhook.status({:ok, [{:duplicate, "clicks_to_join"}]})
200
iex> StatifierRouter.Webhook.status({:error, :no_message_id})
500