Basic Usage

Registering routes

radiant.new()
|> radiant.get("/users", list_users)
|> radiant.post("/users", create_user)
|> radiant.put("/users/<id:int>", update_user)
|> radiant.patch("/users/<id:int>", patch_user)
|> radiant.delete("/users/<id:int>", delete_user)
|> radiant.options("/users", options_users)  // custom OPTIONS handling
|> radiant.any("/health", health_handler)    // same handler for every method `any` covers

any includes GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH, TRACE, CONNECT, and QUERY (RFC 10008). HEAD responses always have an empty body, even when a route is registered explicitly for HEAD.

To register only a QUERY route, add it to a router:

radiant.new()
|> radiant.query_route("/search", search_handler)

The same routing API is also available from radiant/router.

Path parameters

Untyped — extract manually in the handler:

radiant.get("/users/<id:int>", fn(req) {
  case radiant.int_param(req, "id") {
    Ok(id) -> radiant.json("{\"id\":" <> int.to_string(id) <> "}")
    Error(_) -> radiant.not_found()
  }
})

Typed — use get1/get2/… and receive the parsed value directly:

pub const user_id = radiant.int("id")

radiant.get1("/users/<id:int>", user_id, fn(_req, id) {
  // id: Int — already parsed, guaranteed by the router
  radiant.json("{\"id\":" <> int.to_string(id) <> "}")
})

Query parameters

radiant.get("/search", fn(req) {
  let q     = radiant.query(req, "q")         // Result(String, radiant.RadiantError)
  let page  = radiant.query_int(req, "page")  // Result(Int, radiant.RadiantError)
  let sort  = radiant.query_bool(req, "asc")  // Result(Bool, radiant.RadiantError)
  let price = radiant.query_float(req, "max") // Result(Float, radiant.RadiantError)
  let all   = radiant.queries(req)            // List(#(String, String))
  ...
})

Response helpers

radiant.ok("hello")                   // 200 text/plain; charset=utf-8
radiant.json("{\"ok\":true}")         // 200 application/json; charset=utf-8
radiant.html("<h1>hi</h1>")           // 200 text/html; charset=utf-8
radiant.created("resource created")   // 201 text/plain; charset=utf-8
radiant.no_content()                  // 204 empty
radiant.redirect("/login")            // 303

// Error responses — empty body
radiant.bad_request()
radiant.unauthorized()
radiant.forbidden()
radiant.not_found()
radiant.unprocessable_entity()
radiant.internal_server_error()

// Error responses — with body (useful for REST APIs)
radiant.bad_request_with("missing field: email")
radiant.not_found_with("user not found")
radiant.unprocessable_entity_with("email already taken")

// Structured JSON error — {"error": "..."} with application/json
radiant.json_error(404, "user not found")
radiant.json_error(422, "email already taken")

// Custom status + headers
radiant.response(418, "I'm a teapot")
|> radiant.with_header("x-custom", "value")

Type-safe context

Pass data from middleware to handlers without Dynamic:

// Define the key as a module-level constant — shared between middleware and handler
pub const user_key: radiant.Key(User) = radiant.key_named("auth", "user")

// In middleware: store the authenticated user
fn auth_middleware(next) {
  fn(req) {
    case authenticate(req) {
      Ok(user) -> next(radiant.set_context(req, user_key, user))
      Error(_) -> radiant.unauthorized()
    }
  }
}

// In handler: retrieve it — type-safe, no Dynamic
fn profile_handler(req) {
  let assert Ok(user) = radiant.get_context(req, user_key)
  radiant.ok("Hello " <> user.name)
}

Key namespacing: prefer radiant.key_named("auth", "user") over radiant.key("user") to avoid silent collisions when mixing multiple middleware libraries. See middleware.md.

Grouping routes

scope — build routes under a prefix in one call:

radiant.new()
|> radiant.scope("/api/v1", fn(r) {
  r
  |> radiant.get("/users", list_users)
  |> radiant.post("/users", create_user)
  |> radiant.get1("/users/<id:int>", user_id, show_user)
})

mount — attach a pre-built sub-router with its own middleware stack:

let admin_router =
  radiant.new()
  |> radiant.middleware(require_admin)
  |> radiant.get("/users", admin_list_users)

radiant.new()
|> radiant.mount("/admin", admin_router)

scope builds routes in the parent context. mount preserves the sub-router’s middleware stack independently.

Search Document