StatifierRouter.Webhook (StatifierRouter v0.12.0)

Copy Markdown View Source

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_id is a non-empty string (below);
  • :data - the adapter-normalized event, a string-keyed map;
  • :provider_id - the provider's own event id, or nil;
  • :selector - the source's selector, carried for the host's own front and never read here. A binding's selector is 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_id wins 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

What handle/3 answers, and what status/1 reads.

The request a host hands handle/3 once it has verified the signature. The module documentation describes each key.

Functions

Routes one verified webhook request.

The HTTP status the provider should see for one handle/3 answer.

Types

answer()

@type answer() :: {:ok, [StatifierRouter.outcome()]} | {:error, term()}

What handle/3 answers, and what status/1 reads.

request()

@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

handle(config, request, opts \\ [])

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

status(arg)

@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