Ankusa.Routes.Matcher (ankusa v0.7.0)

Copy Markdown View Source

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%2Fb would authorize as /hooks/a/b;
  • . and .. segments are rejections, not resolved (/hooks/a/../b must 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

segment()

@type segment() :: {:literal, String.t()} | :param | :wildcard

Functions

compile(route)

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

match?(arg1, arg2)

@spec match?([segment()], [String.t()]) :: boolean()

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(path_info, request_path)

@spec normalize([String.t()], String.t()) :: {:ok, [String.t()]} | :error

Normalize request-path input into matchable segments, or :error for a path no route may match.

overlap?(list1, list2)

@spec overlap?([segment()], [segment()]) :: boolean()

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.

segments(pattern)

@spec segments(String.t()) :: {:ok, [segment()]} | {:error, String.t()}

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.

specificity(segments)

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