Current section
Files
Jump to
Current section
Files
src/dream@router.erl
-module(dream@router).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/dream/router.gleam").
-export([router/0, build_controller_chain/2, route/5, stream_route/5, find_route/2]).
-export_type([middleware/2, route/2, router/2, route_handler/2, empty_services/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Route configuration and request matching\n"
"\n"
" The router matches incoming requests to controllers based on HTTP method and path patterns.\n"
" It uses a radix trie for O(path depth) lookup performance, making it efficient even with\n"
" hundreds of routes. The router supports path parameters, wildcards, middleware chains,\n"
" and custom context/services types.\n"
"\n"
" ## Basic Routing\n"
"\n"
" ```gleam\n"
" import dream/router.{route, router}\n"
" import dream/http/request.{Get, Post}\n"
"\n"
" pub fn create_router() {\n"
" router()\n"
" |> route(method: Get, path: \"/\", controller: controllers.index, middleware: [])\n"
" |> route(method: Get, path: \"/users/:id\", controller: controllers.show_user, middleware: [])\n"
" |> route(method: Post, path: \"/users\", controller: controllers.create_user, middleware: [])\n"
" }\n"
" ```\n"
"\n"
" ## Path Parameters\n"
"\n"
" Use `:name` to capture path segments as parameters:\n"
" - `/users/:id` matches `/users/123` and extracts `id = \"123\"`\n"
" - `/posts/:post_id/comments/:id` extracts both parameters\n"
"\n"
" Access parameters in your controller with `request.get_param(request, \"id\")`.\n"
"\n"
" ### Parameter Name Remapping\n"
"\n"
" Routes with different parameter names at the same position are supported.\n"
" Each route extracts parameters using its own declared parameter names:\n"
"\n"
" ```gleam\n"
" router()\n"
" |> route(method: Get, path: \"/users/:id\", controller: show_user, middleware: [])\n"
" |> route(method: Get, path: \"/users/:user_id/posts\", controller: show_posts, middleware: [])\n"
" ```\n"
"\n"
" - `/users/123` extracts `id = \"123\"` (first route's param name)\n"
" - `/users/123/posts` extracts `user_id = \"123\"` (second route's param name)\n"
"\n"
" The router automatically remaps parameters to match each route's declared names,\n"
" even when routes share parameter positions in the trie structure.\n"
"\n"
" ## Wildcards\n"
"\n"
" Wildcards match one or more path segments:\n"
" - `*` or `*name` - Matches exactly one segment\n"
" - `**` or `**path` - Matches zero or more segments (greedy)\n"
" - `*.jpg` - Matches any path ending in `.jpg`\n"
" - `*.{jpg,png,gif}` - Matches multiple extensions\n"
"\n"
" ## Middleware\n"
"\n"
" Middleware run before (and optionally after) your controller:\n"
"\n"
" ```gleam\n"
" router()\n"
" |> route(\n"
" method: Get,\n"
" path: \"/admin/users\",\n"
" controller: controllers.admin_users,\n"
" middleware: [auth_middleware, logging_middleware]\n"
" )\n"
" ```\n"
"\n"
" Middleware are executed in order: `auth` → `logging` → controller → `logging` → `auth`.\n"
" Each middleware can modify the request on the way in or the response on the way out.\n"
"\n"
" ## Route Matching Priority\n"
"\n"
" When multiple routes could match, the most specific wins:\n"
" 1. Literal segments (exact match) - highest priority\n"
" 2. Parameters (`:id`)\n"
" 3. Single wildcards (`*`)\n"
" 4. Extension patterns (`*.jpg`)\n"
" 5. Multi-segment wildcards (`**`) - lowest priority\n"
"\n"
" This means `/users/new` will match before `/users/:id` even if defined in reverse order.\n"
"\n"
" ## Important: Parameter Validation Trade-off\n"
"\n"
" Dream validates path parameters at runtime, not compile-time. This means:\n"
"\n"
" - ✅ Ergonomic API: Simple, clean route definitions\n"
" - ✅ Flexible: Easy to add/change routes dynamically\n"
" - ❌ No compile-time safety: Typos in parameter names cause runtime errors\n"
"\n"
" Example of what the compiler WON'T catch:\n"
"\n"
" ```gleam\n"
" // Route definition\n"
" router()\n"
" |> route(method: Get, path: \"/users/:user_id\", controller: show_user, middleware: [])\n"
"\n"
" // Controller (WRONG - will fail at runtime)\n"
" fn show_user(request: Request, context: Context, services: Services) -> Response {\n"
" case get_param(request, \"id\") { // Should be \"user_id\"\n"
" Ok(param) -> // This will never execute\n"
" Error(_) -> // Always hits this branch\n"
" }\n"
" }\n"
" ```\n"
"\n"
" This is an intentional design decision favoring ergonomics over compile-time guarantees.\n"
" We're exploring more type-safe alternatives that maintain clean APIs in\n"
" [GitHub Discussion #15](https://github.com/TrustBound/dream/discussions/15).\n"
"\n"
" **Mitigation:** Write integration tests that exercise your routes. The runtime\n"
" validators (`get_param`, `get_int_param`, etc.) will catch mismatches immediately.\n"
).
-type middleware(ADGG, ADGH) :: {middleware,
fun((dream@http@request:request(), ADGG, ADGH, fun((dream@http@request:request(), ADGG, ADGH) -> dream@http@response:response())) -> dream@http@response:response())}.
-type route(ADGI, ADGJ) :: {route,
dream@http@request:method(),
binary(),
fun((dream@http@request:request(), ADGI, ADGJ) -> dream@http@response:response()),
list(middleware(ADGI, ADGJ)),
boolean()}.
-opaque router(ADGK, ADGL) :: {router,
dream@router@trie:radix_trie(route_handler(ADGK, ADGL))}.
-type route_handler(ADGM, ADGN) :: {route_handler,
fun((dream@http@request:request(), ADGM, ADGN) -> dream@http@response:response()),
list(middleware(ADGM, ADGN)),
boolean(),
list(binary())}.
-type empty_services() :: empty_services.
-file("src/dream/router.gleam", 200).
?DOC(
" Empty router with no routes\n"
"\n"
" Starting point for building your application's router. Chain this with\n"
" `route()` or `stream_route()` calls to add routes.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" router()\n"
" |> route(method: Get, path: \"/\", controller: home, middleware: [])\n"
" |> route(method: Get, path: \"/users/:id\", controller: show_user, middleware: [])\n"
" ```\n"
).
-spec router() -> router(any(), any()).
router() ->
{router, dream@router@trie:new()}.
-file("src/dream/router.gleam", 386).
?DOC(" Check if a segment is non-empty\n").
-spec is_non_empty_segment(binary()) -> boolean().
is_non_empty_segment(Seg) ->
Seg /= <<""/utf8>>.
-file("src/dream/router.gleam", 379).
?DOC(" Split a path into segments for trie lookup\n").
-spec split_path(binary()) -> list(binary()).
split_path(Path) ->
_pipe = Path,
_pipe@1 = gleam@string:split(_pipe, <<"/"/utf8>>),
gleam@list:filter(_pipe@1, fun is_non_empty_segment/1).
-file("src/dream/router.gleam", 409).
?DOC(" Extract param name from a single segment\n").
-spec extract_param_name_from_segment(dream@router@trie:segment()) -> gleam@option:option(binary()).
extract_param_name_from_segment(Segment) ->
case Segment of
{param, Name} ->
{some, Name};
{single_wildcard, {some, Name@1}} ->
{some, Name@1};
{multi_wildcard, {some, Name@2}} ->
{some, Name@2};
{single_wildcard, none} ->
{some, <<"wildcard"/utf8>>};
{multi_wildcard, none} ->
{some, <<"path"/utf8>>};
{extension_pattern, _} ->
none;
{literal, _} ->
none
end.
-file("src/dream/router.gleam", 394).
?DOC(
" Extract param names from segments in path order\n"
"\n"
" Extracts names from Param, SingleWildcard, and MultiWildcard segments.\n"
" Used to store param names with the route handler for remapping after lookup.\n"
).
-spec extract_param_names_from_segments(list(dream@router@trie:segment())) -> list(binary()).
extract_param_names_from_segments(Segments) ->
case Segments of
[] ->
[];
[Segment | Rest] ->
Rest_names = extract_param_names_from_segments(Rest),
Name = extract_param_name_from_segment(Segment),
case Name of
{some, N} ->
[N | Rest_names];
none ->
Rest_names
end
end.
-file("src/dream/router.gleam", 428).
?DOC(
" Remap params extracted during trie traversal to match route's param names\n"
"\n"
" Params are extracted using the trie node's param names (which may differ\n"
" from the route's param names when routes share param positions). This function\n"
" remaps them to the correct names based on the matched route's param_names.\n"
"\n"
" Both lists should be in path order (first param first).\n"
).
-spec remap_params_to_route_names(list({binary(), binary()}), list(binary())) -> list({binary(),
binary()}).
remap_params_to_route_names(Extracted_params, Route_param_names) ->
case {Extracted_params, Route_param_names} of
{[], _} ->
[];
{_, []} ->
Extracted_params;
{[{_, Value} | Rest_params], [New_name | Rest_names]} ->
Remapped_rest = remap_params_to_route_names(Rest_params, Rest_names),
[{New_name, Value} | Remapped_rest]
end.
-file("src/dream/router.gleam", 521).
?DOC(" Apply middleware to a controller, creating a wrapped controller function\n").
-spec apply_middleware_to_controller(
fun((dream@http@request:request(), ADIJ, ADIK, fun((dream@http@request:request(), ADIJ, ADIK) -> dream@http@response:response())) -> dream@http@response:response()),
fun((dream@http@request:request(), ADIJ, ADIK) -> dream@http@response:response())
) -> fun((dream@http@request:request(), ADIJ, ADIK) -> dream@http@response:response()).
apply_middleware_to_controller(Middleware_fn, Controller) ->
fun(Request, Context, Services) ->
Middleware_fn(Request, Context, Services, Controller)
end.
-file("src/dream/router.gleam", 507).
?DOC(
" Create a wrapped controller that applies middleware\n"
"\n"
" This is a named function to avoid anonymous functions in production code.\n"
).
-spec wrap_controller_with_middleware(
fun((dream@http@request:request(), ADIH, ADII, fun((dream@http@request:request(), ADIH, ADII) -> dream@http@response:response())) -> dream@http@response:response()),
fun((dream@http@request:request(), ADIH, ADII) -> dream@http@response:response())
) -> fun((dream@http@request:request(), ADIH, ADII) -> dream@http@response:response()).
wrap_controller_with_middleware(Middleware_fn, Controller) ->
apply_middleware_to_controller(Middleware_fn, Controller).
-file("src/dream/router.gleam", 491).
?DOC(" Wrap a controller with a middleware function\n").
-spec create_wrapped_controller(
fun((dream@http@request:request(), ADIF, ADIG, fun((dream@http@request:request(), ADIF, ADIG) -> dream@http@response:response())) -> dream@http@response:response()),
fun((dream@http@request:request(), ADIF, ADIG) -> dream@http@response:response())
) -> fun((dream@http@request:request(), ADIF, ADIG) -> dream@http@response:response()).
create_wrapped_controller(Middleware_fn, Controller) ->
wrap_controller_with_middleware(Middleware_fn, Controller).
-file("src/dream/router.gleam", 472).
?DOC(" Recursively build the middleware chain\n").
-spec build_chain_recursive(
list(middleware(ADIA, ADIB)),
fun((dream@http@request:request(), ADIA, ADIB) -> dream@http@response:response())
) -> fun((dream@http@request:request(), ADIA, ADIB) -> dream@http@response:response()).
build_chain_recursive(Middleware, Controller) ->
case Middleware of
[] ->
Controller;
[Mw | Rest] ->
case Mw of
{middleware, Middleware_fn} ->
Wrapped_controller = create_wrapped_controller(
Middleware_fn,
Controller
),
build_chain_recursive(Rest, Wrapped_controller)
end
end.
-file("src/dream/router.gleam", 462).
?DOC(
" Build a controller chain from middleware and final controller\n"
"\n"
" Composes middleware with the controller to create a single function. Middleware\n"
" execute in order on the way in, then in reverse order on the way out.\n"
"\n"
" For middleware `[auth, logging]` with controller `handle`:\n"
" Request → auth → logging → handle → logging → auth → Response\n"
"\n"
" ## Parameters\n"
"\n"
" - `middleware`: List of middleware to apply\n"
" - `final_controller`: The controller that handles the request\n"
"\n"
" ## Returns\n"
"\n"
" A composed function that applies all middleware and the controller\n"
).
-spec build_controller_chain(
list(middleware(ADHV, ADHW)),
fun((dream@http@request:request(), ADHV, ADHW) -> dream@http@response:response())
) -> fun((dream@http@request:request(), ADHV, ADHW) -> dream@http@response:response()).
build_controller_chain(Middleware, Final_controller) ->
Reversed = lists:reverse(Middleware),
build_chain_recursive(Reversed, Final_controller).
-file("src/dream/router.gleam", 541).
?DOC(" Wrap a middleware function in the Middleware type\n").
-spec wrap_middleware(
fun((dream@http@request:request(), ADIL, ADIM, fun((dream@http@request:request(), ADIL, ADIM) -> dream@http@response:response())) -> dream@http@response:response())
) -> middleware(ADIL, ADIM).
wrap_middleware(Mw) ->
{middleware, Mw}.
-file("src/dream/router.gleam", 549).
?DOC(" Convert an HTTP method to its string representation\n").
-spec method_to_string(dream@http@request:method()) -> binary().
method_to_string(Method) ->
dream@http@request:method_to_string(Method).
-file("src/dream/router.gleam", 227).
?DOC(
" Add a route to the router\n"
"\n"
" Registers a route with the given method, path pattern, controller, and middleware.\n"
" The path supports parameters (`:id`), wildcards (`*`, `**`), and extensions (`*.jpg`).\n"
"\n"
" Routes are stored in a radix trie, so they don't need to be defined in any particular\n"
" order - the most specific route will always match first.\n"
"\n"
" ## Examples\n"
"\n"
" ```gleam\n"
" // Simple route\n"
" route(router, method: Get, path: \"/\", controller: home_controller, middleware: [])\n"
"\n"
" // Route with path parameter\n"
" route(router, method: Get, path: \"/users/:id\", controller: show_user, middleware: [])\n"
"\n"
" // Route with middleware\n"
" route(router, method: Post, path: \"/admin/users\", controller: create_user, middleware: [auth, logging])\n"
"\n"
" // Wildcard route for static files\n"
" route(router, method: Get, path: \"/assets/**path\", controller: serve_static, middleware: [])\n"
" ```\n"
).
-spec route(
router(ADGS, ADGT),
dream@http@request:method(),
binary(),
fun((dream@http@request:request(), ADGS, ADGT) -> dream@http@response:response()),
list(fun((dream@http@request:request(), ADGS, ADGT, fun((dream@http@request:request(), ADGS, ADGT) -> dream@http@response:response())) -> dream@http@response:response()))
) -> router(ADGS, ADGT).
route(Router_value, Method_value, Path_pattern, Controller_fn, Middleware_list) ->
Segments = dream@router@parser:parse_pattern(Path_pattern),
Method_string = method_to_string(Method_value),
Middleware_wrappers = gleam@list:map(Middleware_list, fun wrap_middleware/1),
Param_names = extract_param_names_from_segments(Segments),
Handler = {route_handler,
Controller_fn,
Middleware_wrappers,
false,
Param_names},
Updated_trie = dream@router@trie:insert(
erlang:element(2, Router_value),
Method_string,
Segments,
Handler
),
{router, Updated_trie}.
-file("src/dream/router.gleam", 282).
?DOC(
" Add a streaming route to the router\n"
"\n"
" Registers a route that receives the request body as a stream (Yielder(BitArray))\n"
" instead of a buffered string. Use this for large file uploads, proxying external\n"
" APIs, or any request body > 10MB.\n"
"\n"
" The controller receives `request.stream` as `Some(Yielder(BitArray))` and\n"
" `request.body` as an empty string. Process chunks as they arrive without\n"
" buffering the entire body in memory.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" router\n"
" |> stream_route(method: Post, path: \"/upload\", controller: handle_upload, middleware: [auth])\n"
" |> stream_route(method: Put, path: \"/files/:id\", controller: replace_file, middleware: [])\n"
" ```\n"
"\n"
" ## When to Use\n"
"\n"
" - File uploads > 10MB\n"
" - Proxying external APIs\n"
" - Video/audio streaming\n"
" - Large form submissions\n"
"\n"
" For regular requests (JSON APIs, forms < 10MB), use `route()` instead.\n"
).
-spec stream_route(
router(ADGZ, ADHA),
dream@http@request:method(),
binary(),
fun((dream@http@request:request(), ADGZ, ADHA) -> dream@http@response:response()),
list(fun((dream@http@request:request(), ADGZ, ADHA, fun((dream@http@request:request(), ADGZ, ADHA) -> dream@http@response:response())) -> dream@http@response:response()))
) -> router(ADGZ, ADHA).
stream_route(
Router_value,
Method_value,
Path_pattern,
Controller_fn,
Middleware_list
) ->
Segments = dream@router@parser:parse_pattern(Path_pattern),
Method_string = method_to_string(Method_value),
Middleware_wrappers = gleam@list:map(Middleware_list, fun wrap_middleware/1),
Param_names = extract_param_names_from_segments(Segments),
Handler = {route_handler,
Controller_fn,
Middleware_wrappers,
true,
Param_names},
Updated_trie = dream@router@trie:insert(
erlang:element(2, Router_value),
Method_string,
Segments,
Handler
),
{router, Updated_trie}.
-file("src/dream/router.gleam", 346).
?DOC(
" Find the route matching the request\n"
"\n"
" Searches the router's trie for a route that matches the request's method and path.\n"
" Returns the matched route and extracted path parameters, or None if no route matches.\n"
"\n"
" Uses radix trie lookup for O(path depth) performance, independent of total routes.\n"
"\n"
" ## Parameters\n"
"\n"
" - `router_value`: Router with configured routes\n"
" - `request`: HTTP request to match against\n"
"\n"
" ## Returns\n"
"\n"
" - `Some(#(route, params))`: Matched route and extracted path parameters\n"
" - `None`: No matching route found\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let app_router = router\n"
" |> route(method: Get, path: \"/users/:id\", controller: show_user, middleware: [])\n"
"\n"
" case find_route(app_router, request) {\n"
" Some(#(route, params)) -> {\n"
" // route.controller is show_user\n"
" // params is [#(\"id\", \"123\")] if path was \"/users/123\"\n"
" }\n"
" None -> // No route matched\n"
" }\n"
" ```\n"
).
-spec find_route(router(ADHG, ADHH), dream@http@request:request()) -> gleam@option:option({route(ADHG, ADHH),
list({binary(), binary()})}).
find_route(Router_value, Request) ->
Method_string = method_to_string(erlang:element(2, Request)),
Path_segments = split_path(erlang:element(5, Request)),
case dream@router@trie:lookup(
erlang:element(2, Router_value),
Method_string,
Path_segments
) of
{some, {match, Handler, Params}} ->
Reversed_params = lists:reverse(Params),
Remapped_params = remap_params_to_route_names(
Reversed_params,
erlang:element(5, Handler)
),
Final_params = lists:reverse(Remapped_params),
Route = {route,
erlang:element(2, Request),
erlang:element(5, Request),
erlang:element(2, Handler),
erlang:element(3, Handler),
erlang:element(4, Handler)},
{some, {Route, Final_params}};
none ->
none
end.