radiant

Package Version Hex Docs

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

API

Everything is a single import: import radiant.

Router Construction

FunctionDescription
radiant.new()Empty router (404 fallback)
radiant.get, post, put, patch, deleteRegister 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

ConstructorTypeMatches
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

FunctionDescription
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

FunctionStatusDescription
radiant.ok(body)200Plain text response
radiant.created(body)201Resource created
radiant.no_content()204Empty response
radiant.bad_request()400Malformed request
radiant.unauthorized()401Authentication required
radiant.forbidden()403Access denied
radiant.not_found()404Resource not found
radiant.unprocessable_entity()422Semantic validation failure
radiant.internal_server_error()500Server-side failure
radiant.redirect(uri)303See Other redirect
radiant.json(body)200Sets content-type: application/json
radiant.html(body)200Sets 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:

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

PatternPriorityMatchesCaptured as
/users/admin1 — highestexact string admin
/users/<id:int>2integer segments onlyString (use int_param or get1 with radiant.int)
/users/:id or <name:string>3any segmentString
/files/*rest4 — lowestall remaining segmentsString (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
Search Document