Path patterns: the grammar (segments/1), the compiled form
(compile/1), matching (match?/2), and request-path normalization
(normalize/2).
A compiled pattern is a list of segment/0s — no regex, no backtracking,
no way for a crafted request path to cost more than its own length. Literals
compare with ==; :param consumes exactly one segment; :wildcard (always
last) requires one or more remaining segments, so /hooks/shopify/* is
/hooks/shopify/<something> and a bare /hooks/shopify needs its own route.
Normalization
normalize/2 is the only place a request path is turned into matchable
segments, so both the guard and the dry run agree on what "the same path"
means:
- a percent-encoded slash (
%2F, any case) is a rejection, not a segment boundary — otherwise/hooks/a%2Fbwould authorize as/hooks/a/b; .and..segments are rejections, not resolved (/hooks/a/../bmust not become/hooks/b);- empty segments from
//or a trailing/are dropped, and a path with nothing left is a rejection.
Plug does not percent-decode path_info: Plug.Conn.Adapter only splits
the path on /, so the segments the guard sees are the raw, still-encoded
ones and a %2F never turns into a segment boundary here. It is rejected all
the same — an encoded slash is never a legitimate route path, and a later stage
that did decode it would see a different path than the one that was authorized
— and it is detected on the raw request_path.
Because segments are matched raw, a literal in a route pattern is limited to
characters that never need percent-encoding (see segments/1), so a pattern
that no real request could match is refused when the route is written rather
than accepted and silently dead. An encoded request segment matches only a
:param or the wildcard, never a literal: /hooks/%73tripe is not
/hooks/stripe. Encoded dots (%2e%2e) are ordinary opaque segments, not
... Nothing in Ankusa decodes a path, so nothing in Ankusa resolves one —
but the request path is recorded verbatim in the envelope and reaches whatever
consumes it, so a consumer that decodes it owns that step. The guard's job is
only that the route it matched is the one the sender addressed.
Summary
Functions
Compile a route's path pattern. Raises if the pattern is invalid, which can
only happen for a %Route{} that was not built through
Ankusa.Routes.Route.from_attrs/2.
Does a compiled pattern match these request segments?
Normalize request-path input into matchable segments, or :error for a path
no route may match.
Can one request path match both compiled patterns? Literals must agree at
every position, a :param agrees with any single segment, and a :wildcard
(always last, one or more segments) agrees with whatever remains of the
other pattern as long as something does.
Validate a path pattern and compile it to segments.
The sort key for candidate ordering: more literal segments first, then
patterns without a wildcard. Ties are broken by route id, which
Ankusa.Routes.Snapshot does.
Types
@type segment() :: {:literal, String.t()} | :param | :wildcard
Functions
@spec compile(Ankusa.Routes.Route.t()) :: [segment()]
Compile a route's path pattern. Raises if the pattern is invalid, which can
only happen for a %Route{} that was not built through
Ankusa.Routes.Route.from_attrs/2.
Does a compiled pattern match these request segments?
Both sides are non-empty-segment lists (normalize/2 and segments/1 drop
empties), so a :param never matches an empty string.
Normalize request-path input into matchable segments, or :error for a path
no route may match.
Can one request path match both compiled patterns? Literals must agree at
every position, a :param agrees with any single segment, and a :wildcard
(always last, one or more segments) agrees with whatever remains of the
other pattern as long as something does.
This is what "two routes shadow each other" means; the route table's
Ankusa.Routes.Snapshot.overlaps/4 finds the candidates through its prefix
index and asks this about each.
Validate a path pattern and compile it to segments.
{:error, message} is the message that goes straight into a route
validation error, so it is phrased as a field complaint.
@spec specificity([segment()]) :: {non_neg_integer(), boolean()}
The sort key for candidate ordering: more literal segments first, then
patterns without a wildcard. Ties are broken by route id, which
Ankusa.Routes.Snapshot does.