Middleware

Middleware wraps every request that passes through the router with radiant.middleware/2, or one handler with radiant.wrap/2. First added = outermost.

radiant.new()
|> radiant.middleware(radiant.cors(radiant.default_cors()))
|> radiant.middleware(radiant.log(io.println))
|> radiant.middleware(radiant.rescue(fn(err) {
  io.debug(err)
  radiant.internal_server_error()
}))
|> radiant.get("/", handler)

Built-in middleware

cors

// Default: any origin, GET/POST/PUT/PATCH/DELETE, content-type + authorization, 24h cache
radiant.middleware(radiant.cors(radiant.default_cors()))

// Custom config
radiant.middleware(radiant.cors(radiant.CorsConfig(
  origins: ["https://myapp.com"],
  methods: [http.Get, http.Post],
  headers: ["content-type", "authorization", "x-api-key"],
  max_age: 3600,
)))

Handles OPTIONS preflight automatically. Access-Control-Allow-Origin is only emitted when an allowed Origin header is present in the request. A normal OPTIONS request is passed to the router, and CORS responses include Vary: Origin.

log

// Any fn(String) -> a works
radiant.middleware(radiant.log(io.println))

// With woof
radiant.middleware(radiant.log(fn(msg) { woof.log(logger, woof.Info, msg, []) }))

Logs METHOD /path before the handler and METHOD /path → STATUS after.

rescue

radiant.middleware(radiant.rescue(fn(err) {
  io.debug(err)
  radiant.internal_server_error()
}))

Catches Erlang exceptions (panics, crashes) in handlers and returns a response instead of crashing the process.

json_body

Parse the request body as JSON and store the result in context. Returns 400 if the body is not valid JSON or doesn’t match the decoder. Skips parsing when the body is empty (GET, HEAD, DELETE).

pub const payload_key: radiant.Key(CreateUser) = radiant.key_named("routes", "create_user")

let decoder = {
  use name  <- decode.field("name", decode.string)
  use email <- decode.field("email", decode.string)
  decode.success(CreateUser(name:, email:))
}

radiant.new()
|> radiant.middleware(radiant.json_body(payload_key, decoder))
|> radiant.post("/users", fn(req) {
  let assert Ok(payload) = radiant.get_context(req, payload_key)
  // payload: CreateUser — no Dynamic, no decoding in the handler
  ...
})

serve_static

let fs = radiant.FileSystem(
  read_bits: simplifile.read_bits,
  is_file: simplifile.is_file,
)

radiant.middleware(radiant.serve_static(prefix: "/assets", from: "priv/static", via: fs))

Strips the prefix, resolves the file path inside the directory, and serves it with MIME detection. Falls through to the next handler if the file doesn’t exist. Use any IO library or an in-memory implementation for tests.

Custom middleware

A middleware is fn(fn(Req) -> Response) -> fn(Req) -> Response.

fn timing_middleware(next: fn(radiant.Req) -> Response(BitArray)) -> fn(radiant.Req) -> Response(BitArray) {
  fn(req) {
    let start = erlang.system_time(erlang.Millisecond)
    let resp  = next(req)
    let elapsed = erlang.system_time(erlang.Millisecond) - start
    resp |> radiant.with_header("x-response-time", int.to_string(elapsed) <> "ms")
  }
}

router |> radiant.middleware(timing_middleware)

Route-specific middleware

Use wrap when only one route needs a middleware:

let create_user =
  radiant.wrap(
    radiant.json_body(payload_key, user_decoder),
    fn(req) {
      let assert Ok(user) = radiant.get_context(req, payload_key)
      radiant.created(user.name)
    },
  )

router
|> radiant.post("/users", create_user)
|> radiant.get("/health", health_handler)

This keeps JSON parsing off unrelated routes and also works well for route-specific authentication or authorization.

Context key namespacing

radiant.key("user") is backed by the string "user". If two middlewares call key("user") but expect different types, they silently overwrite each other in the context dict.

Always use namespaced key names:

// Bad — collides with any other "user" key
pub const user_key = radiant.key("user")

// Good — module-qualified, collision-safe
pub const user_key = radiant.key_named("auth_middleware", "user")
pub const session_key = radiant.key_named("session_middleware", "session")
pub const payload_key = radiant.key_named("routes", "create_user")

This matters most when you mix application and third-party middleware. Key(a) protects the value type, but the runtime identity is still the string name.

Search Document