Routing
Edda's router is a handful of combinators that compose ordinary
functions into an app. Each route is a Request -> Response function
that either produces a response or signals "I don't match — try the
next one."
The shape of a route
A route is just a function:
fun show_user : Request -> Response
show_user req = text 200 "ok"Wrapped with route, it becomes a matcher:
route GET "/users/:id" show_userroute returns a function that runs the handler if the method and
path pattern match, and skip!s otherwise. The Skip effect is what
the router uses to walk past non-matching routes; choose discharges
it.
choose
fun app : Request -> Response
app req = choose [
route GET "/", home,
route GET "/users", list_users,
route POST "/users", create_user,
route GET "/users/:id", show_user,
] reqchoose tries each route in order. The first one that doesn't skip!
wins. If all of them skip, you get the default not_found (404).
Routes can declare effects they need. The router lets each route declare its own set — they don't have to agree:
choose [
route GET "/health", health, # pure
route GET "/me", me, # needs {Auth}
route GET "/items/:id", show_item, # needs {Db, Log}
]choose widens the effect row across the list, so the surrounding
context has to discharge the union of everything any route inside it
needs. See the middleware guide for how to install
those handlers.
Path parameters
Segments starting with : capture into the request's params dict:
route GET "/users/:id/posts/:post_id" show_post
fun show_post : Request -> Response
show_post req = case (param "id" req, param "post_id" req) {
(Just uid, Just pid) -> text 200 $"user {uid}, post {pid}"
_ -> text 400 "missing params"
}param is String -> Request -> Maybe String. Captured values are
strings; convert as needed at the call site.
group: inline nesting
group matches a path prefix and runs inner routes against the
remainder. The sub-routes share the parent's effect row, so this is
the right tool when you want to factor out a common URL prefix without
changing how handlers are wired.
choose [
group "/users" [
route GET "/", list_users,
route POST "/", create_user,
route GET "/:id", show_user,
],
]Captured params accumulate across nesting, so:
group "/users/:id" [
route GET "/posts", list_user_posts, # can read `id`
]works as expected.
mount: attach a sub-app
mount attaches an already-pure Request -> Response at a prefix.
Use it when a sub-app has its own effect stack that should stay
encapsulated — typically because it lives in its own module and
discharges its own handlers internally.
mount "/admin" admin_app
mount "/api" api_appA mounted sub-app sees the path with the prefix stripped. Inside
admin_app, requests to /admin/users arrive with req.path = "/users". The unmodified path stays available as req.original_path
for things like logging.
When to use which
groupwhen sub-routes need the parent's effects and should stay in the same file.mountwhen a sub-app is its own thing — different effect stack, its own module, its own tests. Real-world example: a public API and an admin API that need different DB pools.
Where the prefix goes
group and mount both strip the matched prefix from req.path
before handing the request down. Sub-app authors think in relative
paths.
| Field | What it has |
|---|---|
req.path | The path the current matcher sees (stripped). |
req.original_path | The full path as it came in off the wire. |
Fallthrough and not_found
If nothing in a choose matches, the request falls through to
Edda's not_found (a 404 with body "not found"). To customize, add
a catch-all at the bottom:
choose [
route GET "/health", health,
...
fun req -> text 404 $"no route for {method_str req.method} {req.original_path}",
]A plain lambda is a perfectly valid route — it doesn't have a method
or pattern, so it always matches. The router treats Request -> Response as the most general possible route signature.