roadrunner_router (roadrunner v0.9.2)

View Source

Path → 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 via roadrunner_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. Only path and handler are required; an absent methods answers 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.

Functions

Compile a list of routes into the lookup form match/3 expects.

Look up the handler for a given request method + path.

Check a route table without building it, returning the verdict as a value.

Types

bindings()

-type bindings() :: #{binary() => binary() | [binary()]}.

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.

compiled()

-opaque compiled()

The compiled-routes representation match/3 consumes. Treat as opaque: the shape is an implementation detail and may change.

methods()

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

route()

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

routes()

-type routes() :: [route()].

An ordered list of routes; matched first-to-last.

validation_error()

-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} — a methods key 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. Position is the 1-based index in the table and ShadowedBy lists the {Position, Path} of every earlier route taking work away from it.

Functions

compile(Routes, ListenerMws)

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

match(Method, Path, Compiled)

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

validate(Routes)

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