radiant
A focused, type-safe HTTP router for Gleam on BEAM.
Radiant is built on a prefix tree and provides a declarative, composable routing experience with zero global state and no macros.
gleam add radiant
Quick example
import radiant
import gleam/int
pub fn router() -> radiant.Router {
radiant.new()
|> radiant.get("/", fn(_req) { radiant.ok("hello") })
// Typed parameters: 404 if 'id' is not an integer
|> radiant.get("/users/<id:int>", fn(req) {
let assert Ok(id) = radiant.int_param(req, "id")
radiant.json("{\"id\":" <> int.to_string(id) <> "}")
})
|> radiant.scope("/api/v1", fn(r) {
r |> radiant.post("/items", create_item)
})
}
Features
- Prefix Tree Backend: Literal segment lookup is O(1) via an internal
Dict; matching cost grows with path depth, not route count. - Specificity-based Priority: Match priority follows
Literal > <id:int> > <name:string> > *wildcard, regardless of registration order. No surprises. - Typed Parameters: Constrain segments directly in the pattern:
/users/<id:int>or/docs/<name:string>. Non-matching segments fall through to the next route. - Typed Routes (
get1/get2/get3): DeclareParam(a)objects and the handler receives parsed, typed values directly — noassert, no string parsing in user code. - Router Mounting: Compose large applications by mounting sub-routers:
router |> mount("/auth", auth_router). - Type-safe Context: Pass strongly-typed data through the middleware chain using
Key(a)— noDynamic, no manual decoding. - JSON Middleware: Built-in
json_bodyfor automatic parsing; the decoded value lands in context already typed. - Swappable Static Server: Serve assets via the
serve_staticmiddleware with aFileSysteminterface (works withsimplifileor any other library). - Automatic 405: Returns
Method Not Allowedwith the correctAllowheader when a path matches but the method doesn’t. - HEAD support: HEAD requests automatically fall through to the registered GET handler and return an empty body, per RFC 9110.
API
Everything is a single import: import radiant.
Router Construction
| Function | Description |
|---|---|
radiant.new() | Empty router (404 fallback) |
radiant.get, post, put, patch, delete | Register a route for a specific method |
radiant.options(r, pattern, fn) | Register a route for OPTIONS requests |
radiant.any(r, pattern, fn) | Register a handler for all standard HTTP methods |
radiant.get1, post1, ... | Typed route — 1 path parameter delivered to the handler |
radiant.get2, post2, ... | Typed route — 2 path parameters delivered to the handler |
radiant.get3, post3, ... | Typed route — 3 path parameters delivered to the handler |
radiant.scope(r, prefix, fn) | Group routes under a prefix |
radiant.mount(r, prefix, sub) | Attach a pre-built sub-router to a prefix |
radiant.middleware(r, mw) | Apply a middleware to the router |
radiant.fallback(r, handler) | Custom handler for unmatched requests |
radiant.routes(r) | List all registered routes as (Method, String) pairs |
Path Parameter Objects
| Constructor | Type | Matches |
|---|---|---|
radiant.int("name") | Param(Int) | Integer segments only — the handler receives an Int |
radiant.str("name") | Param(String) | Any segment — the handler receives a String |
Request & Context
| Function | Description |
|---|---|
radiant.key(name) | Create a typed context key Key(a) |
radiant.set_context(req, key, val) | Store any typed value in the request context |
radiant.get_context(req, key) | Retrieve a typed value — returns Result(a, Nil) |
radiant.str_param(req, name) | Extract a path segment as String |
radiant.int_param(req, name) | Extract a path segment as Int |
radiant.text_body(req) | Get the request body as a UTF-8 string |
Response Helpers
| Function | Status | Description |
|---|---|---|
radiant.ok(body) | 200 | Plain text response |
radiant.created(body) | 201 | Resource created |
radiant.no_content() | 204 | Empty response |
radiant.bad_request() | 400 | Malformed request |
radiant.unauthorized() | 401 | Authentication required |
radiant.forbidden() | 403 | Access denied |
radiant.not_found() | 404 | Resource not found |
radiant.unprocessable_entity() | 422 | Semantic validation failure |
radiant.internal_server_error() | 500 | Server-side failure |
radiant.redirect(uri) | 303 | See Other redirect |
radiant.json(body) | 200 | Sets content-type: application/json |
radiant.html(body) | 200 | Sets content-type: text/html |
radiant.with_header(resp, k, v) | — | Add/overwrite a response header |
Specialized Middlewares
JSON Body Parsing
Define a Key(a) constant to share between the middleware and the handler:
pub const user_key: radiant.Key(User) = radiant.key("user")
let user_decoder = {
use name <- decode.field("name", decode.string)
decode.success(User(name))
}
router
|> radiant.middleware(radiant.json_body(user_key, user_decoder))
|> radiant.post("/users", fn(req) {
// get_context returns Result(User, Nil) — no Dynamic, no decoding
let assert Ok(user) = radiant.get_context(req, user_key)
radiant.ok("Hello " <> user.name)
})
Static File Serving
Radiant uses a FileSystem interface so you can use simplifile now or swap later:
let fs = radiant.FileSystem(read_bits: simplifile.read_bits, is_file: simplifile.is_file)
router |> radiant.middleware(radiant.serve_static("/assets", "public", fs))
Typed Routes
get1 / get2 / get3 (and their post, put, patch, delete variants) let you
declare Param(a) objects and receive parsed, typed values directly in the handler —
no assert, no string parsing in user code.
import radiant
// Declare params once — reuse for routing and URL building
pub const user_id = radiant.int("id")
pub const post_id = radiant.int("pid")
pub fn router() -> radiant.Router {
radiant.new()
// Handler receives (req, Int) — id is already an Int
|> radiant.get1("/users/<id:int>", user_id, fn(req, id) {
radiant.json("{\"id\":" <> int.to_string(id) <> "}")
})
// Handler receives (req, Int, Int) — both params parsed
|> radiant.get2("/users/<id:int>/posts/<pid:int>", user_id, post_id, fn(req, uid, pid) {
radiant.json("{\"user\":" <> int.to_string(uid) <> ",\"post\":" <> int.to_string(pid) <> "}")
})
}
Type safety guarantees:
- The
Param(Int)/Param(String)type propagates to the handler signature — mismatches are compile errors. - At startup, if a
Paramname does not appear in the pattern, Radiant panics immediately with a clear message. - No
Dynamic, noassert Ok(...), no manualint.parsein handler code.
Testing
Radiant provides fluent assertions and synthetic request helpers to test your logic without a running server.
pub fn my_test() {
my_router()
|> radiant.handle(radiant.test_get("/api/users/42"))
|> radiant.should_have_status(200)
|> radiant.should_have_json_body(user_decoder)
|> should.equal(User(id: 42))
}
Request helpers: test_get, test_post, test_put, test_patch, test_delete, test_head, test_options, test_request.
Assertion helpers: should_have_status, should_have_body, should_have_header, should_have_json_body.
With Mist
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http/response
import mist
import radiant
pub fn main() {
let router = my_router()
let assert Ok(_) =
mist.new(fn(req) {
let resp = radiant.handle(router, req)
response.set_body(resp, mist.Bytes(bytes_tree.from_bit_array(resp.body)))
})
|> mist.port(8080)
|> mist.start()
process.sleep_forever()
}
With Wisp
wisp.Request uses a Connection body type, so use handle_with which accepts
the body read separately:
pub fn handle_request(req: wisp.Request) -> wisp.Response {
use <- wisp.log_request(req)
use body <- wisp.require_bit_array_body(req)
radiant.handle_with(router, req, body)
}
If you don’t need Wisp’s middleware, use Mist + radiant directly.
Reverse routing
Build URLs from patterns — no string concatenation, no broken links:
radiant.path_for("/users/<id:int>", [#("id", "42")])
// → Ok("/users/42")
radiant.path_for("/users/<uid:int>/posts/<pid:int>", [
#("uid", "1"), #("pid", "99"),
])
// → Ok("/users/1/posts/99")
radiant.path_for("/users/<id:int>", [])
// → Error(Nil) ← missing param caught at runtime
Use the same pattern string for both routing and URL generation:
pub const user_path = "/users/<id:int>"
// Register the route
router |> radiant.get(user_path, user_handler)
// Build a redirect URL
radiant.path_for(user_path, [#("id", int.to_string(new_id))])
|> result.map(radiant.redirect)
Route introspection
List all registered routes — useful for startup logging, contract tests, or generating documentation:
radiant.routes(my_router())
// → [
// #(http.Get, "/"),
// #(http.Get, "/users/<id:int>"),
// #(http.Post, "/users"),
// ]
Path parameter syntax
| Pattern | Priority | Matches | Captured as |
|---|---|---|---|
/users/admin | 1 — highest | exact string admin | — |
/users/<id:int> | 2 | integer segments only | String (use int_param or get1 with radiant.int) |
/users/:id or <name:string> | 3 | any segment | String |
/files/*rest | 4 — lowest | all remaining segments | String (joined with /) |
Priority is structural, not based on registration order. /users/admin always
matches before /users/<id:int>, even if the capture was registered first.
<id:int> always matches before <name:string> for integer segments.
Use get1/get2/get3 with radiant.int("id") to receive the value already
parsed — no int_param call needed in the handler.
Non-goals
Radiant does not provide: template rendering, sessions, cookies, authentication, or WebSockets. For those, use the underlying server (Mist) or a full framework (Wisp) directly.
Development
gleam test # Run the test suite
gleam dev # Start the demo server on :4000