One route definition: which requests the edge captures when route management is on.
A route is a capture gate that is owned by a tenant and may be bound to
a source. It decides method and path matching and, optionally, which client
addresses may use it; Ankusa.RouteResolver still maps the accepted request
to a source_id and tenant_id, and the route then has to agree with that
answer (Ankusa.Edge.Ingest.lookup/3):
tenant_id(default"default") — the owning tenant. A route owned by a tenant admits hooks only into that tenant: a request whose resolver names another tenant, or whose source belongs to another tenant, is a404. The tenant"default"is the shared one (as it is for a source): such a route is not tied to a tenant. A route owned by a tenant, matched on a shared source with no tenant in the URL, files the hook under the route's tenant.source_id(defaultnil) — the one source the route admits. A request the resolver maps to another source is a404;nilleaves it to the resolver.
Path patterns
Segments are /-separated and one of three things:
- a literal (
hooks,stripe.com,2024-01-01) — matched with==; - a param (
:tenant,:source_id) — exactly one segment, any value; - the wildcard
*, legal only as the last segment — one or more remaining segments, so/hooks/shopify/*matches/hooks/shopify/a/band not/hooks/shopify.
No regex: it invites ReDoS in a component that runs before any authentication,
and neither the :tenant nor the * form needs it.
Validation
Every rule lives in from_attrs/2, so the admin API, the config seed, and a
Redis-stored definition are all parsed by the same code and produce the same
error tuples. {:invalid, field, message} names the offending field and, where
the field is a list, the index.
Summary
Functions
Build a route from client or config attributes.
The inverse of to_json/1, for a definition read back from a store.
A UTC timestamp truncated to the second: the granularity these ids keep.
Parse a global rule list — the admin API's PUT /admin/ip-rules body and the
Redis store's ip_rules value are both this shape — into the store form.
Parse an ordered rule list into ip_rule/0s, naming the index of the first
bad rule.
The JSON view of one IP rule, for the admin API.
The JSON view of a whole global rule list (%{default: ..., rules: [...]}).
Coerce a map or keyword list into a string-keyed map. field names the value
in error tuples: a non-keyword list is "must be a map" (the stricter
ip_rules message), anything else non-map is "must be a map or keyword list".
The JSON view of a route: what the admin API returns and Redis stores.
The JSON a store persists: to_json/1 without the binding fields a route
that has none (tenant_id: "default", source_id: nil) leaves out, so a
node that predates them still reads every definition written before a tenant
route exists (from_json/1 gives the omitted fields their defaults).
Types
@type t() :: %Ankusa.Routes.Route{ enabled: boolean(), id: String.t(), inserted_at: DateTime.t(), ip_rules: [ip_rule()], metadata: map(), methods: [String.t()], path: String.t(), revision: pos_integer(), source_id: String.t() | nil, tenant_id: String.t(), updated_at: DateTime.t() }
Functions
@spec from_attrs(map() | keyword(), keyword()) :: {:ok, t()} | {:error, {:invalid, String.t(), String.t()}}
Build a route from client or config attributes.
opts[:id] wins over attrs["id"]: on PUT/PATCH the id in the URL is the
one being addressed, and a body naming a different one must not silently move
the route. An id is generated when neither is present.
Attrs are a map or keyword list with string or atom keys; inserted_at and
updated_at are not settable, so an update cannot forge its own history.
The inverse of to_json/1, for a definition read back from a store.
@spec new_timestamp() :: DateTime.t()
A UTC timestamp truncated to the second: the granularity these ids keep.
@spec parse_ip_rules(term()) :: {:ok, %{default: :allow | :deny, rules: [ip_rule()]}} | {:error, {:invalid, String.t(), String.t()}}
Parse a global rule list — the admin API's PUT /admin/ip-rules body and the
Redis store's ip_rules value are both this shape — into the store form.
default and rules are both required. This replaces a security control
wholesale, so nothing is filled in on the caller's behalf: an omitted default
used to mean allow, which quietly turned a deny-by-default list into an open
one.
Parse an ordered rule list into ip_rule/0s, naming the index of the first
bad rule.
Shared by a route's own ip_rules, config.routes.ip_rules.rules, and the
admin API's PUT /admin/ip-rules body, so a rule that is accepted in one place
is accepted in all three and the message is the same.
The JSON view of one IP rule, for the admin API.
The JSON view of a whole global rule list (%{default: ..., rules: [...]}).
The admin API's /admin/ip-rules body and the Redis store's ip_rules value
are this shape, so a rule renders identically wherever it is read from.
@spec stringify(term(), String.t()) :: {:ok, %{required(String.t()) => term()}} | {:error, {:invalid, String.t(), String.t()}}
Coerce a map or keyword list into a string-keyed map. field names the value
in error tuples: a non-keyword list is "must be a map" (the stricter
ip_rules message), anything else non-map is "must be a map or keyword list".
The JSON view of a route: what the admin API returns and Redis stores.
The JSON a store persists: to_json/1 without the binding fields a route
that has none (tenant_id: "default", source_id: nil) leaves out, so a
node that predates them still reads every definition written before a tenant
route exists (from_json/1 gives the omitted fields their defaults).