roadrunner_router (roadrunner v0.9.1)

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

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.