Packages
Elixir client for Ankusa: receive HTTP-sink deliveries, decode queue messages, redeem claim checks, and drive the routes/admin/sources APIs.
Current section
Files
Jump to
Current section
Files
ankusa_sdk
README.md
README.md
# ankusa_sdk
The Elixir client SDK for [Ankusa](https://github.com/jamescarr/ankusa)
deployments: everything an Elixir consumer needs on the *subscribe* side of the
delivery, plus clients for the operator APIs.
- **Receive `Ankusa.Sink.Http` deliveries** with a `Plug`
(`Ankusa.SDK.Receiver`) that hands each hook to your handler module.
- **Decode queue messages** (`Ankusa.SDK.Message`) published by
`Ankusa.Sink.RabbitMQ`/`Kafka`/`NATS`/`Redis`, and redeem the claim check
when the payload was too large to ride inline. The SDK ships **no** broker
client: bring your own Broadway/AMQP/brod/gnat/Redix consumer.
- **Redeem claim checks** (`Ankusa.SDK.ClaimCheck`) with the end-to-end sha256
check the gateway itself does not run.
- **Drive the routes, admin, and sources APIs** (`Ankusa.SDK.Routes`,
`Ankusa.SDK.Admin`, `Ankusa.SDK.Sources`).
The SDK never depends on `ankusa` core and never defines the bare `Ankusa`
module, so an app may load both.
```text
HTTP sink Ankusa --> Ankusa.SDK.Receiver ---------------------+
|
Queue sinks Ankusa --> your consumer --> Message.decode/1 +--> MyApp.Hooks.handle_hook/2
Message.to_hook/2 -----+
(redeems a claim through Ankusa.SDK.ClaimCheck)
```
## Install
```elixir
# mix.exs
def deps do
[
{:ankusa_sdk, "~> 0.5"}
]
end
```
Everything the SDK exposes lives under `Ankusa.SDK.`.
## Claim-check client
Redeem a claim-check ref, verify the bytes it returns against the message's
`sha256`, and classify failures into dead-letter vs. retry — without holding any
object-store credentials.
```elixir
claim_check = Ankusa.SDK.ClaimCheck.new(ENV.fetch("CLAIM_CHECK_URL", "http://localhost:4001"))
# claim and sha256 are the queue message's fields:
# "claim": "urn:ankusa:claim:v1:<tenant>:<claim_id>" (claim_id: uppercase ULID)
# "sha256": 64-char lowercase hex of the claim's bytes
case Ankusa.SDK.ClaimCheck.redeem(claim_check, claim, sha256) do
{:ok, body} ->
handle(body)
{:error, %{retryable: false} = error} ->
# bad ref/sha256, 404, or an integrity mismatch: dead-letter, don't requeue
dead_letter(error)
{:error, %{retryable: true} = error} ->
# gateway unreachable or 5xx: safe to retry
requeue(error)
end
```
`redeem/3` does three things a bare `GET /v1/claims/...` doesn't:
1. Parses the ref into its tenant, claim id, and gateway path
(`Ankusa.SDK.ClaimRef.parse/1`, also usable on its own).
2. Fetches the bytes.
3. Verifies them against the message's `sha256` (the gateway itself does not
check this — see "Redeem a claim" in
[`docs/claim-check.md`](https://github.com/jamescarr/ankusa/blob/main/docs/claim-check.md))
before returning them.
Every failure carries `retryable`, so a consumer needs exactly one bit:
| Error | `retryable` | Cause |
| --- | --- | --- |
| `Ankusa.SDK.InvalidClaimRefError` | `false` | `ref` isn't a well-formed claim-check URN, or `sha256` isn't 64-char lowercase hex |
| `Ankusa.SDK.ClaimNotFoundError` | `false` | gateway `404`: expired by retention, or never written |
| `Ankusa.SDK.ClaimRejectedError` | `false` | gateway `4xx` other than `404`, `408`, `429` (`:status`, `:body`) |
| `Ankusa.SDK.ClaimIntegrityError` | `false` | sha256 of the returned bytes doesn't match |
| `Ankusa.SDK.ClaimCheckUnavailableError` | `true` | gateway unreachable, `408`, `429`, or answered anything else |
`health/1` hits `GET /health` for a liveness probe.
## Webhook receiver
`Ankusa.SDK.Receiver` is a `Plug` that reads the raw body, parses the
`x-ankusa-*` headers, calls your handler, and answers `202`, `503`, or `4xx`
matching what `Ankusa.Sink.Http` treats as success.
```elixir
defmodule MyApp.Hooks do
@behaviour Ankusa.SDK.Handler
@impl Ankusa.SDK.Handler
def handle_hook(%Ankusa.SDK.Hook{} = hook, _arg) do
# Return :ok only once the hook is durably handled; delivery is
# at-least-once, so dedupe on the idempotency key
# (Ankusa.SDK.Idempotency.key/2), not on the arrival alone.
case MyApp.Store.insert(hook.id, hook.body) do
:inserted -> :ok
:duplicate -> :ok
{:error, reason} -> {:error, reason}
end
end
end
```
Standalone (Bandit):
```elixir
# mix.exs: {:bandit, "~> 1.12"}
children = [
{Bandit, plug: {Ankusa.SDK.Receiver, handler: MyApp.Hooks}, port: 4200}
]
```
In a Phoenix endpoint, mount the receiver **above** the body parsers:
```elixir
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug Ankusa.SDK.Receiver, path: "/deliveries", handler: MyApp.Hooks
plug Plug.Parsers, parsers: [:json], json_decoder: JSON
# … the rest of the endpoint
end
```
Options: `:handler` (a module, or `{module, arg}`), `:path` (only that path is
handled; everything else passes through untouched), `:max_body_bytes`
(default `8_000_000`, the same cap core's ingest applies to a raw body), and
`:secret` / `:tolerance_seconds` for a signed sink (below).
### Signed deliveries
An HTTP sink with a `secret` signs every delivery the
[Standard Webhooks](https://www.standardwebhooks.com/) way. Give the receiver
the same secret and it verifies each delivery before your handler sees it,
answering `401` `{"error":"invalid_signature","code":...}` otherwise:
```elixir
plug Ankusa.SDK.Receiver,
handler: MyApp.Hooks,
secret: System.fetch_env!("ANKUSA_WHSEC"),
tolerance_seconds: 300
```
Secrets are `whsec_` + base64, or any other string used as its own bytes;
pass a list during a rotation. The check on its own is
`Ankusa.SDK.Signature.verify/4`, returning `{:error,
%Ankusa.SDK.InvalidSignatureError{code: ..., field: ...}}` (never retryable);
the comparison is `:crypto.hash_equals/2`.
The handler sees one value whichever transport delivered the hook:
| Field | HTTP | Queue |
| --- | --- | --- |
| `id` | `x-ankusa-id` | message `id` |
| `source_id` | `x-ankusa-source` | message `source_id` |
| `tenant_id` | `x-ankusa-tenant` | message `tenant_id` |
| `content_type` | `content-type` | message `content_type` |
| `body` | the raw request body | inline body, or the redeemed claim |
| `received_at` | `nil` (the HTTP sink doesn't send it) | message `received_at`, unix ms |
| `size` | `byte_size(body)` | message `size` |
| `dedupe_key` | `x-ankusa-dedupe-key` | message `dedupe_key` |
| `replay_id` | `x-ankusa-replay-id` | message `replay_id` |
| `idempotency_key` | `x-ankusa-idempotency-key` | message `idempotency_key` |
| `headers` | every request header, lowercased | the forwarded provider headers |
Dedupe on the key `Ankusa.SDK.Idempotency.key/2` returns (see "Consuming queue
messages" below), not on `id` alone: a provider retry can reach you as several
hook ids.
The header parsing on its own is `Ankusa.SDK.Webhook.parse_headers/1`, for
receivers built on something else.
## Queue messages
`Ankusa.SDK.Message.decode/1` parses the `Ankusa.Sink.Message` JSON that
RabbitMQ, Kafka, NATS, and Redis sinks publish; `Message.to_hook/2` turns it
into the same `Ankusa.SDK.Hook` the Plug delivers, redeeming the claim check
when the payload lived in the claim store. A `ClaimCheck` client is required
even for inline messages, so a consumer cannot forget it the day a payload
crosses the sink's `inline_max_bytes` (64 KiB by default).
A consumer of any broker looks the same:
```elixir
# A Broadway/AMQP/Redix callback, whatever your consumer calls it.
def handle_message(message_body, claim_check) do
with {:ok, message} <- Ankusa.SDK.Message.decode(message_body),
{:ok, hook} <- Ankusa.SDK.Message.to_hook(message, claim_check) do
case MyApp.Hooks.handle_hook(hook, []) do
:ok -> :ack
{:error, _reason} -> :requeue
end
else
# Undecodable bytes (InvalidMessageError), a bad ref, a 404, an integrity
# mismatch: every one is `retryable: false` — dead-letter, don't requeue.
{:error, %{retryable: false} = error} -> {:dlq, error}
# An unreachable or 5xx gateway: safe to retry.
{:error, %{retryable: true} = error} -> {:requeue, error}
end
end
```
`decode/1` returns an `Ankusa.SDK.InvalidMessageError` with a stable `code`
(`"invalid_json"`, `"not_an_object"`, `"unsupported_version"`, `"invalid_field"`,
`"ambiguous_body"`, `"missing_body"`, `"invalid_body_base64"`,
`"size_mismatch"`, `"integrity"`, `"tenant_mismatch"`) and, when the code is
`"invalid_field"`, the offending `field`. It also verifies the inline body's
length against `size`, its bytes against `sha256`, and a claim's tenant against
`tenant_id`. Keys it doesn't know are ignored, so a newer producer adding a
field doesn't break an older consumer.
Kafka/NATS headers and the RabbitMQ routing key are not read: the JSON body
carries everything.
## Consuming queue messages
A queue consumer is at-least-once end to end, so the handler must make its
effect idempotent. Decode the message, compute the idempotency key, and
insert-or-ignore it into a processed-ids table before doing the work:
```elixir
defmodule MyApp.Consumer do
alias Ankusa.SDK.{Idempotency, Message}
def handle(raw, claim_check) do
with {:ok, message} <- Message.decode(raw),
{:ok, hook} <- Message.to_hook(message, claim_check),
key = Idempotency.key(message) do
case MyApp.Store.claim(key, hook.id) do
:inserted -> MyApp.Hooks.handle_hook(hook, [])
:duplicate -> :ok
end
else
# Undecodable bytes and every bad-ref/404/integrity failure are
# `retryable: false` — dead-letter, don't requeue.
{:error, %{retryable: false} = error} -> {:dlq, error}
# An unreachable or 5xx gateway is safe to retry.
{:error, %{retryable: true} = error} -> {:requeue, error}
end
end
end
```
The key is the table's primary key, so the database enforces the dedupe:
```elixir
defmodule MyApp.Repo.Migrations.CreateProcessedHooks do
use Ecto.Migration
def change do
create table(:processed_hooks, primary_key: false) do
add :idempotency_key, :text, primary_key: true
add :ankusa_id, :text, null: false
add :processed_at, :utc_datetime_usec
end
create index(:processed_hooks, [:ankusa_id])
end
end
```
`Ankusa.SDK.Idempotency.key/2` accepts the decoded `Message`, the parsed
`Ankusa.SDK.Webhook.Headers`, or a `Hook`. Ankusa computes the key once per hook
and ships it (the message's `idempotency_key`, the `x-ankusa-idempotency-key`
header), and `key/2` returns that value. It is `tenant:source_id:dedupe_key`
when the source extracted a provider event key (Stripe's `id`, GitHub's
`x-github-delivery`, ...) and the hook id otherwise, so a provider's own
retries collapse even when ingest minted a fresh hook id for each one, and two
tenants that share a provider event id stay apart. For a message or delivery
from a node that predates the field `key/2` computes the same key itself
(tenant `default` when there is none). A replay of an already-processed
delivery computes the same key and is dropped; pass `include_replay: true` when
the consumer must reprocess replays:
```elixir
key = Ankusa.SDK.Idempotency.key(message, include_replay: true)
```
## Routes client
Manage route definitions and the global IP rules on the route-management
listener (`routes.admin.port`, default `4003`).
```elixir
routes = Ankusa.SDK.Routes.new(ENV.fetch("ROUTES_URL", "http://localhost:4003"))
{:ok, page} = Ankusa.SDK.Routes.list_routes(routes, enabled: true, limit: 10)
{:ok, _} = Ankusa.SDK.Routes.create_route(routes, %{"id" => "stripe", "path" => "/webhooks/stripe"})
{:ok, rules} = Ankusa.SDK.Routes.get_ip_rules(routes)
```
Functions: `health/1`, `list_routes/2`, `create_route/2`, `get_route/2`,
`replace_route/3`, `update_route/3`, `delete_route/2`, `get_ip_rules/1`,
`put_ip_rules/2`, `test_route/2`. Route ids are percent-encoded as one path
segment, so `/`, `?`, `#`, `%` and a space in an id can't reshape the URL; an
id that isn't a string, is empty, or is `.`/`..` is refused with
`Ankusa.SDK.InvalidRouteIdError` before any request is sent.
Failures: `InvalidRouteIdError`, `RouteNotFoundError` (`404`),
`RoutesRejectedError` (any other `4xx`, carrying `:code`, `:field`, `:message`,
`:conflicting_id`, `:max_routes`), and `RoutesUnavailableError` (`5xx`, an
unfollowed redirect, a non-JSON success body, or unreachable; retryable).
## Admin client
The operator API on `admin.port` (default `4002`): health, Prometheus metrics,
the redacted config, the DLQ, replay jobs, and the quarantine list.
```elixir
admin = Ankusa.SDK.Admin.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
{:ok, _} = Ankusa.SDK.Admin.health(admin)
{:ok, text} = Ankusa.SDK.Admin.metrics(admin)
{:ok, %{"total" => total}} = Ankusa.SDK.Admin.list_dead_letters(admin, limit: 10)
{:ok, _} = Ankusa.SDK.Admin.list_quarantined(admin)
# Replay jobs: re-send the dead-letter queue ("dlq"), an archived time window
# ("archive"), or re-verify held hooks and release those that pass ("quarantine").
{:ok, replay} = Ankusa.SDK.Admin.create_replay(admin, %{"kind" => "dlq", "rate" => 500})
{:ok, replay} = Ankusa.SDK.Admin.get_replay(admin, replay["id"])
{:ok, %{"replays" => replays}} = Ankusa.SDK.Admin.list_replays(admin)
{:ok, paused} = Ankusa.SDK.Admin.update_replay(admin, replay["id"], %{"state" => "paused"})
```
Failures: `RoleNotEnabledError` (`409 role_not_enabled`, carrying `:role`),
`AdminRejectedError` (any other `4xx`, carrying `:code` — a missing job is
`404 replay_not_found`), and `AdminUnavailableError` (`5xx`, an unfollowed
redirect, a non-JSON success body, or unreachable; retryable).
## Sources client
Sources are tenant-scoped: a source is addressed as `<tenant>.<name>`, and
`Ankusa.Admin.Router` serves their CRUD API on the same `admin.port` as the
operator API.
```elixir
sources = Ankusa.SDK.Sources.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
{:ok, list} = Ankusa.SDK.Sources.list_sources(sources, "acme")
{:ok, source} = Ankusa.SDK.Sources.get_source(sources, "acme", "billing")
spec = %Ankusa.SDK.Sources.Spec{sinks: [%{"type" => "log"}]}
{:ok, _} = Ankusa.SDK.Sources.create_source(sources, "acme", "billing", spec)
{:ok, _} = Ankusa.SDK.Sources.update_source(sources, "acme", "billing", spec)
:ok = Ankusa.SDK.Sources.delete_source(sources, "acme", "billing")
```
A read returns an `Ankusa.SDK.Sources.Source`, which is always redacted —
resending a read-back `verify` map is not the same as resending the stored
secret, so supply secrets through `Ankusa.SDK.Sources.Spec`.
`expected_version: "0.3.0"` is an optional latch: the client is immutable, so
run `Sources.verify_version/1` once at startup and keep the returned client;
every later call then re-checks the cached version without another `/health`
request, and a mismatch is an `Ankusa.SDK.VersionMismatchError` before any API
call.
```elixir
{:ok, sources} =
Ankusa.SDK.Sources.new("http://localhost:4002", expected_version: "0.3.0")
|> Ankusa.SDK.Sources.verify_version()
```
Failures, all carrying `:status` and `:body`: `SourceNotFoundError` (`404`),
`SourceConflictError` (`409`), `SourceStoreReadOnlyError` (`409` — the
deployment's source store is a static seed), `SourceInvalidError` (`400`, or an
invalid tenant/name caught before any request), `VersionMismatchError`, and
`SourcesUnavailableError` (unreachable, timed out, or `5xx`). Tenants and names
must match `[A-Za-z0-9_-]{1,64}`.
## Layout
```
lib/ankusa/sdk/
http.ex # the shared Req layer (@moduledoc false)
errors.ex # every exception the SDK returns
hook.ex # one delivered hook, either transport
handler.ex # the behaviour a handler implements
webhook.ex # x-ankusa-* header parsing
receiver.ex # the Plug that receives HTTP-sink deliveries
message.ex # queue wire format: decode + to_hook
idempotency.ex # the idempotency key for any delivery
claim_ref.ex # urn:ankusa:claim:v1:<tenant>:<claim_id>
claim_check.ex # the claim-check gateway client
routes.ex # the route-management client
admin.ex # the operator client
sources.ex # the tenant-scoped source client
sources/spec.ex # the writable spec
sources/source.ex # a stored, redacted source
test/
conformance_test.exs # runs the language-neutral vectors in conformance/
support/ # the mock gateway, transports, recorder
```
## Develop
```sh
mix deps.get
mix test # unit suites + the conformance vectors
mix test test/conformance_test.exs # just the vectors
```
The same vectors every other SDK runs live in
[`conformance/`](https://github.com/jamescarr/ankusa/tree/main/conformance);
`mise run check:conformance` runs them all.