roadrunner_router (roadrunner v0.9.2)
View SourcePath → handler dispatch with parameterized segments.
A route is either a tuple shorthand or a map. Both forms share the
same Path and Handler:
{Path, Handler}— only routes the path; no state, no per-route middlewares.{Path, Handler, State}— adds opaque per-handler state surfaced viaroadrunner_req:state/1.#{path => Path, handler => Handler, state => State, middlewares => [Mw, ...], methods => [~"GET", ...]}— full map form. Use this when you want to attach per-route middlewares, an HTTP-method allowlist, or any future per-route framework knob. Onlypathandhandlerare required; an absentmethodsanswers every method.
The tuple shorthand intentionally cannot carry middlewares — that keeps the simple case syntactically light and pushes "more than just state" to the more verbose map form.
A route may restrict the HTTP methods it answers via the map form's
methods key (a list of uppercase method binaries, e.g.
[~"GET", ~"POST"]). A request whose path matches a route but whose
method is not in that route's list does not match — match/3 keeps
scanning, and if no route on that path accepts the method it returns
{method_not_allowed, Allowed} carrying the union of the methods
declared by the path-matching routes (for a 405 Allow header). A
route with no methods answers every method.
Path is a binary like /users/:id/posts/:post_id. Segments
starting with : capture a single segment into bindings keyed by
the binary name that follows the colon — we deliberately avoid
binary_to_atom/1 on the parsed name to keep the "everything is
binary on the wire" rule we already use for header names.
Segments starting with * (e.g. /static/*path) are wildcard
captures: they consume all remaining path segments and bind them as
a list under the given name. A wildcard must be the last segment in
a pattern; a segment after it could never match, so compile/2
raises {invalid_route_path, Path, wildcard_not_last} rather than
registering a route that can never answer.
Literal segments must match byte-exactly; comparison is case-sensitive per RFC 3986.
Routes are tried in declaration order, earlier entries win, and
nothing is reordered by specificity. To keep that rule from failing
silently, compile/2 rejects a route the entries above it already
answer in full: write /static/*path above /static/assets/*path
and the second one can never be reached, so compiling raises
{unreachable_route, Position, Path, ShadowedBy} naming both.
Ordering stays the caller's to choose; getting it wrong stops the
listener from booting instead of turning into a 404 in production.
The opaque compiled() shape is a list of pre-parsed segment
patterns; swapping to a trie/DAG later is a non-breaking change for
callers.
Summary
Types
Captured route parameters, populated by match/3.
The compiled-routes representation match/3 consumes. Treat as
opaque: the shape is an implementation detail and may change.
An HTTP-method allowlist for a route: a list of uppercase method
binaries ([~"GET", ~"POST"]), or undefined to answer every method.
compile/2 turns the list into a #{Method => true} set-map so match
time is an O(1) is_map_key/2 rather than a list scan; methods are
matched byte-exact against roadrunner_req:method/1 (already uppercase
on the wire), so callers must pass uppercase.
A single route entry. Three shapes are accepted
An ordered list of routes; matched first-to-last.
Why a route table was rejected, as returned by validate/1 and raised by
compile/2.
Types
Captured route parameters, populated by match/3.
:param segments produce a single binary value
(#{~"id" => ~"42"}). *wildcard segments produce the list of
remaining path segments
(#{~"rest" => [~"a", ~"b"]}). Empty for routes with no captures.
Captured values are percent-decoded (/users/caf%C3%A9 binds
<<"café"/utf8>>), consistent with query params; a segment whose
percent-escapes are malformed (a % not followed by two hex digits)
is kept raw. Decoding does not validate UTF-8, so %FF binds the raw
0xFF byte. There is no + -> space translation -- that is a
query-string convention, so a literal + in a path stays a +.
Because decoding happens after the path is split on raw /, a decoded
value can contain a / (%2F), a .., or a leading / — it is not a
single clean path component. A handler that builds a filesystem path or
an outbound URL from a captured value must reject .. and absolute
segments itself; see roadrunner_static for the reference check.
-opaque compiled()
The compiled-routes representation match/3 consumes. Treat as
opaque: the shape is an implementation detail and may change.
-type methods() :: [binary()] | undefined.
An HTTP-method allowlist for a route: a list of uppercase method
binaries ([~"GET", ~"POST"]), or undefined to answer every method.
compile/2 turns the list into a #{Method => true} set-map so match
time is an O(1) is_map_key/2 rather than a list scan; methods are
matched byte-exact against roadrunner_req:method/1 (already uppercase
on the wire), so callers must pass uppercase.
Matching is literal: a [~"GET"] route does not implicitly answer
HEAD (or any other verb) -- list every method the route accepts.
A present methods must be a non-empty list of binaries; compile/2
raises {invalid_route_methods, _} on an empty list or non-binary
entries (both would otherwise silently reject every request).
-type route() :: {Path :: binary(), Handler :: module()} | {Path :: binary(), Handler :: module(), State :: term()} | #{path := binary(), handler := module(), state => term(), middlewares => roadrunner_middleware:middleware_list(), methods => methods()}.
A single route entry. Three shapes are accepted:
{Path, Handler}— shorthand: no state, no middlewares.{Path, Handler, State}— shorthand with state only.#{path := Path, handler := Handler, state => State, middlewares => Mws, methods => [~"GET", ...]}— map form; use this to attach per-route middlewares, an HTTP-method allowlist, or future per-route framework knobs.
Path is a binary pattern (literal segments, :param captures, or
*wildcard catch-all). Handler is the module implementing
roadrunner_handler. State is opaque per-route data threaded back
to the handler via roadrunner_req:state/1; unset → undefined.
methods is a list of uppercase method binaries the route answers;
unset → every method.
-type routes() :: [route()].
An ordered list of routes; matched first-to-last.
-type validation_error() :: {invalid_route, route()} | {invalid_route_methods, term()} | {invalid_route_path, binary(), wildcard_not_last} | {unreachable_route, pos_integer(), binary(), [{pos_integer(), binary()}]}.
Why a route table was rejected, as returned by validate/1 and raised by
compile/2.
{invalid_route, Route}— the entry is not one of the accepted shapes, or its path is not a binary / its handler is not a module atom.{invalid_route_methods, Methods}— amethodskey that is not a non-empty list of binaries.{invalid_route_path, Path, wildcard_not_last}— a segment after the pattern's*wildcard, which no request could ever reach.{unreachable_route, Position, Path, ShadowedBy}— the entries above this one already answer its path for every method it declares.Positionis the 1-based index in the table andShadowedBylists the{Position, Path}of every earlier route taking work away from it.
Functions
-spec compile(routes(), roadrunner_middleware:middleware_list()) -> compiled().
Compile a list of routes into the lookup form match/3 expects.
Each path is split on / (empty leading/trailing segments dropped),
and segments starting with : are recorded as named captures.
Raises any validation_error/0 before running a middleware
init/1, so a table that cannot work never has side effects. Call
validate/1 first to get the same verdict as a value instead.
Only a route left with nothing to answer is unreachable. Two routes
on the same path declaring disjoint methods both stay reachable,
which is how same-path method dispatch works.
ListenerMws is the listener-wide middleware list; it is resolved
once (running each module's init/1 a single time) and reused
across every route, composed outermost around each route's own
middlewares (with any per-route state injected before middlewares
run). The conn loop calls the composed fun straight with the request —
zero closure allocations per request. Pass [] for ListenerMws
when compiling routes outside a listener (typically only in tests).
-spec match(Method :: binary(), Path :: binary(), compiled()) -> {ok, module(), bindings(), roadrunner_middleware:next(), term()} | {method_not_allowed, [binary()]} | not_found.
Look up the handler for a given request method + path.
Returns {ok, Handler, Bindings, Pipeline, State} on a match —
Bindings is a map populated with captures from :param segments
(empty for purely literal routes); Pipeline is the pre-composed
next() fun built at compile time (listener mws ++ per-route mws,
optionally wrapped in a state-injecting outermost closure, ending in
fun Handler:handle/1); State is the per-route opaque state
attached by the user at compile time (or undefined when the route
shape didn't carry any). The conn loop just calls Pipeline —
State is for callers who need to introspect a route outside the
request flow.
Method is the uppercase request-method binary. A route with no
methods allowlist answers every method; otherwise the method must be
a member. When a route's path matches but its method does not, the
scan continues (so a later same-path route can answer the method —
that is how same-path method dispatch works). If at least one route's
path matched but none answered the method, returns
{method_not_allowed, Allowed} where Allowed is the sorted,
de-duplicated union of those routes' methods (for a 405 Allow
header). Returns not_found when no compiled route's path matches at
all.
-spec validate(routes()) -> ok | {error, validation_error()}.
Check a route table without building it, returning the verdict as a value.
compile/2 runs exactly this and raises on {error, Reason}, so a
table validate/1 accepts is one compile/2 will not reject. Use it
where raising is the wrong answer: roadrunner_listener:reload_routes/2
validates a replacement table this way so a bad one is refused without
disturbing the table already serving.
Covers everything decidable from the table itself: entry shapes, method
allowlists, wildcard placement, and whether every route can be reached.
It does not run middleware init/1, so a per-route middleware that
crashes on init still surfaces at compile/2 time.