//// A trie-based HTTP router for Gleam on BEAM. //// One import, no sub-modules. See the [README](https://hexdocs.pm/radiant/) for examples. import exception import gleam/bit_array import gleam/dict.{type Dict} import gleam/dynamic import gleam/dynamic/decode import gleam/http.{type Method, Delete, Get, Patch, Post, Put} import gleam/http/request.{type Request, Request} import gleam/http/response.{type Response, Response} import gleam/int import gleam/json as gjson import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string import gleam/uri import radiant/internal/path as ipath import radiant/internal/tree // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /// An opaque router that maps HTTP method + path pattern to handlers. pub opaque type Router { Router( tree: tree.Node(fn(Req) -> Response(BitArray)), fallback: fn(Req) -> Response(BitArray), middlewares: List(Middleware), ) } /// A middleware transforms a handler into a new handler. /// Use this for cross-cutting concerns: logging, CORS, timing, auth. pub type Middleware = fn(fn(Req) -> Response(BitArray)) -> fn(Req) -> Response(BitArray) /// A swappable interface for file system operations. /// This allows using `simplifile` now and switching to other libraries like `fio` later. pub type FileSystem { FileSystem( read_bits: fn(String) -> Result(BitArray, Nil), is_file: fn(String) -> Bool, ) } /// An opaque request wrapper carrying the original request, extracted /// path parameters, and a typed context for middlewares to pass data. pub opaque type Req { Req( request: Request(BitArray), params: Dict(String, String), context: Dict(String, dynamic.Dynamic), ) } /// A typed key for the request context. The phantom type `a` ensures that /// the value retrieved from the context always matches the type stored. /// /// Define keys as module-level constants for maximum safety: /// /// ```gleam /// pub const user_key: radiant.Key(User) = radiant.key("user") /// ``` pub opaque type Key(a) { Key(String) } /// A typed path parameter for use with `get1`, `get2`, `get3`, etc. /// Carries the parameter name, its parse function, and the type constraint /// used by the routing tree. /// /// Create with `radiant.int(name)` or `radiant.str(name)`. pub opaque type Param(a) { Param( name: String, parse: fn(String) -> Result(a, Nil), ptype: ipath.ParamType, ) } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- /// A `Param(Int)` that matches only integer path segments. /// For use with `get1`, `get2`, `get3`, etc. /// /// ```gleam /// router |> radiant.get1("/users/:id", radiant.int("id"), fn(req, id) { /// radiant.ok(int.to_string(id)) // id: Int, guaranteed /// }) /// ``` pub fn int(name: String) -> Param(Int) { Param(name: name, parse: int.parse, ptype: ipath.IntT) } /// A `Param(String)` that matches any path segment. /// For use with `get1`, `get2`, `get3`, etc. /// /// ```gleam /// router |> radiant.get1("/posts/:slug", radiant.str("slug"), fn(req, slug) { /// radiant.ok(slug) // slug: String, no extraction needed /// }) /// ``` pub fn str(name: String) -> Param(String) { Param(name: name, parse: fn(s) { Ok(s) }, ptype: ipath.StringT) } /// Create a typed context key. Pair with `set_context` and `get_context` /// for type-safe request context passing through middlewares. /// /// ```gleam /// pub const user_key: radiant.Key(User) = radiant.key("user") /// /// // In middleware: /// radiant.set_context(req, user_key, authenticated_user) /// /// // In handler: /// let assert Ok(user) = radiant.get_context(req, user_key) /// ``` pub fn key(name: String) -> Key(a) { Key(name) } /// Create an empty router with a default 404 fallback. pub fn new() -> Router { Router(tree: tree.new(), fallback: fn(_req) { not_found() }, middlewares: []) } /// Set a custom fallback handler for unmatched requests. pub fn fallback( router: Router, handler: fn(Req) -> Response(BitArray), ) -> Router { Router(..router, fallback: handler) } /// Add a middleware that wraps every request through this router. /// Middlewares are applied in the order they are added (first added = outermost). /// /// ```gleam /// radiant.new() /// |> radiant.middleware(radiant.log(fn(msg) { io.println(msg) })) /// |> radiant.middleware(radiant.cors(radiant.default_cors())) /// |> radiant.get("/", handler) /// ``` pub fn middleware(router: Router, mw: Middleware) -> Router { Router(..router, middlewares: [mw, ..router.middlewares]) } /// Register a GET route. pub fn get( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, Get, pattern, handler) } /// Register a POST route. pub fn post( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, Post, pattern, handler) } /// Register a PUT route. pub fn put( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, Put, pattern, handler) } /// Register a PATCH route. pub fn patch( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, Patch, pattern, handler) } /// Register a DELETE route. pub fn delete( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, Delete, pattern, handler) } /// Register an OPTIONS route. /// Useful for custom preflight handling when the `cors` middleware is not used. pub fn options( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route(router, http.Options, pattern, handler) } // --------------------------------------------------------------------------- // Typed route registration (get1 / get2 / get3 and equivalents) // --------------------------------------------------------------------------- /// Register a GET route with one typed path parameter. /// The handler receives the extracted value directly — no manual extraction. /// /// ```gleam /// router |> radiant.get1("/users/:id", radiant.int("id"), fn(req, id) { /// radiant.ok("User " <> int.to_string(id)) /// }) /// ``` pub fn get1( router: Router, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { typed1(router, Get, pattern, p1, handler) } pub fn post1( router: Router, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { typed1(router, Post, pattern, p1, handler) } pub fn put1( router: Router, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { typed1(router, Put, pattern, p1, handler) } pub fn patch1( router: Router, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { typed1(router, Patch, pattern, p1, handler) } pub fn delete1( router: Router, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { typed1(router, Delete, pattern, p1, handler) } /// Register a GET route with two typed path parameters. /// /// ```gleam /// router |> radiant.get2( /// "/users/:uid/posts/:pid", /// radiant.int("uid"), radiant.int("pid"), /// fn(req, uid, pid) { radiant.ok(int.to_string(uid) <> "/" <> int.to_string(pid)) }, /// ) /// ``` pub fn get2( router: Router, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { typed2(router, Get, pattern, p1, p2, handler) } pub fn post2( router: Router, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { typed2(router, Post, pattern, p1, p2, handler) } pub fn put2( router: Router, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { typed2(router, Put, pattern, p1, p2, handler) } pub fn patch2( router: Router, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { typed2(router, Patch, pattern, p1, p2, handler) } pub fn delete2( router: Router, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { typed2(router, Delete, pattern, p1, p2, handler) } /// Register a GET route with three typed path parameters. pub fn get3( router: Router, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { typed3(router, Get, pattern, p1, p2, p3, handler) } pub fn post3( router: Router, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { typed3(router, Post, pattern, p1, p2, p3, handler) } pub fn put3( router: Router, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { typed3(router, Put, pattern, p1, p2, p3, handler) } pub fn patch3( router: Router, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { typed3(router, Patch, pattern, p1, p2, p3, handler) } pub fn delete3( router: Router, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { typed3(router, Delete, pattern, p1, p2, p3, handler) } /// Group routes under a common path prefix. /// /// ```gleam /// radiant.new() /// |> radiant.scope("/api/v1", fn(r) { /// r /// |> radiant.get("/users", list_users) /// |> radiant.get("/users/:id", show_user) /// }) /// ``` pub fn scope( router: Router, prefix: String, builder: fn(Router) -> Router, ) -> Router { let scoped = builder(new()) let prefix_segs = ipath.parse(prefix) let sub_routes = tree.to_routes(scoped.tree) list.fold(sub_routes, router, fn(acc_router, r) { let #(method, segments, handler) = r let full_segments = list.append(prefix_segs, segments) let new_tree = tree.insert(acc_router.tree, method, full_segments, handler) Router(..acc_router, tree: new_tree) }) } /// Mount a complete sub-router at a given path prefix. /// /// Unlike `scope`, which builds routes in the same context, `mount` allows /// you to define independent routers (with their own middlewares) and /// attach them to a parent router. /// /// Note: The sub-router's fallback handler is ignored. Unmatched requests /// will fall through to the parent router's fallback. pub fn mount(router: Router, prefix: String, sub_router: Router) -> Router { let prefix_segs = ipath.parse(prefix) let sub_routes = tree.to_routes(sub_router.tree) list.fold(sub_routes, router, fn(acc_router, r) { let #(method, segments, handler) = r // Apply sub_router's middlewares to the handler (stored newest-first) let with_middlewares = list.fold(sub_router.middlewares, handler, fn(h, mw) { mw(h) }) let full_segments = list.append(prefix_segs, segments) let new_tree = tree.insert(acc_router.tree, method, full_segments, with_middlewares) Router(..acc_router, tree: new_tree) }) } /// Dispatch a raw HTTP request through the router, returning a response. /// /// This is the main entry point connecting radiant to any HTTP server. /// Middlewares are applied in registration order (first added = outermost). /// /// If the path matches a route but the method does not, returns 405 Method /// Not Allowed with an `allow` header listing the accepted methods. pub fn handle(router: Router, req: Request(BitArray)) -> Response(BitArray) { let dispatch = fn(r: Req) -> Response(BitArray) { let segments = ipath.split(r.request.path) case tree.match(router.tree, r.request.method, segments) { Ok(#(handler, params)) -> handler(Req(..r, params: params)) Error(Nil) -> { // HEAD falls through to GET per HTTP/1.1 spec (RFC 9110 §9.3.2). // Run the GET handler and strip the response body. let head_result = case r.request.method { http.Head -> case tree.match(router.tree, Get, segments) { Ok(#(handler, params)) -> Ok(handler( Req( ..r, params: params, request: Request(..r.request, method: Get), ), )) Error(_) -> Error(Nil) } _ -> Error(Nil) } case head_result { Ok(resp) -> Response(..resp, body: <<>>) Error(_) -> { let allowed = tree.allowed_methods(router.tree, segments) case allowed { [] -> router.fallback(r) _ -> method_not_allowed( allowed |> list.map(http.method_to_string) |> string.join(", "), ) } } } } } } // middlewares is stored newest-first; fold left so the first-added // ends up as the outermost wrapper (executes first). let final_handler = list.fold(router.middlewares, dispatch, fn(handler, mw) { mw(handler) }) final_handler(Req(request: req, params: dict.new(), context: dict.new())) } /// Like `handle`, but accepts a request with any body type plus a separately /// read `BitArray` body. Useful for Wisp integration where the body is read /// through `wisp.require_bit_array_body` before routing. /// /// ```gleam /// use body <- wisp.require_bit_array_body(req) /// radiant.handle_with(router, req, body) /// ``` pub fn handle_with( router: Router, req: Request(anything), body: BitArray, ) -> Response(BitArray) { handle(router, request.set_body(req, body)) } /// Register the same handler for all standard HTTP methods on a pattern. /// Useful for health check endpoints or method-agnostic catch-alls. /// /// ```gleam /// radiant.new() /// |> radiant.any("/health", fn(_req) { radiant.ok("ok") }) /// ``` pub fn any( router: Router, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { [Get, Post, Put, Patch, Delete] |> list.fold(router, fn(r, m) { add_route(r, m, pattern, handler) }) } /// Return all registered routes as `(method, pattern)` pairs. /// /// Useful for logging the route table at startup, contract tests, /// or building documentation from a live router. /// /// ```gleam /// radiant.routes(router) /// // → [#(http.Get, "/"), #(http.Get, "/users/"), ...] /// ``` pub fn routes(router: Router) -> List(#(Method, String)) { tree.to_routes(router.tree) |> list.map(fn(r) { let #(method, segments, _handler) = r #(method, segments_to_pattern(segments)) }) } fn segments_to_pattern(segments: List(ipath.Segment)) -> String { case segments { [] -> "/" _ -> "/" <> list.map(segments, fn(seg) { case seg { ipath.Literal(s) -> s ipath.Capture(name, ipath.IntT) -> "<" <> name <> ":int>" ipath.Capture(name, ipath.StringT) -> "<" <> name <> ":string>" ipath.Wildcard(name) -> "*" <> name } }) |> string.join("/") } } /// Build a URL path from a route pattern and a list of `(name, value)` pairs. /// /// Returns `Error(Nil)` if any named parameter is missing from the list. /// Extra keys in the list are silently ignored. /// /// ```gleam /// radiant.path_for("/users//posts/", [ /// #("id", "42"), #("pid", "7"), /// ]) /// // → Ok("/users/42/posts/7") /// /// radiant.path_for("/users/", []) /// // → Error(Nil) /// ``` pub fn path_for( pattern: String, params: List(#(String, String)), ) -> Result(String, Nil) { let segments = ipath.parse(pattern) let lookup = dict.from_list(params) use parts <- result.try( list.try_map(segments, fn(seg) { case seg { ipath.Literal(s) -> Ok(s) ipath.Capture(name, _) -> dict.get(lookup, name) ipath.Wildcard(name) -> dict.get(lookup, name) } }), ) case parts { [] -> Ok("/") _ -> Ok("/" <> string.join(parts, "/")) } } fn add_route( router: Router, method: Method, pattern: String, handler: fn(Req) -> Response(BitArray), ) -> Router { add_route_raw(router, method, ipath.parse(pattern), handler) } fn add_route_raw( router: Router, method: Method, segments: List(ipath.Segment), handler: fn(Req) -> Response(BitArray), ) -> Router { case tree.get_handler(router.tree, segments, method) { Ok(_) -> router Error(_) -> Router( ..router, tree: tree.insert(router.tree, method, segments, handler), ) } } // Verify that a Param's name refers to an actual capture in the pattern. // Panics at route-registration time (startup) if mismatched, not at request time. fn validate_param( p: Param(a), segments: List(ipath.Segment), pattern: String, ) -> Nil { case list.any(segments, fn(seg) { case seg { ipath.Capture(name, _) -> name == p.name ipath.Wildcard(name) -> name == p.name _ -> False } }) { True -> Nil False -> panic as { "Radiant: param '" <> p.name <> "' not found in pattern '" <> pattern <> "'. The Param name must match a capture in the route pattern." } } } // Upgrade the type constraint of a named capture to match a Param's ptype. fn apply_param_type( segments: List(ipath.Segment), p: Param(a), ) -> List(ipath.Segment) { list.map(segments, fn(seg) { case seg { ipath.Capture(name, _) if name == p.name -> ipath.Capture(name, p.ptype) _ -> seg } }) } fn typed1( router: Router, method: Method, pattern: String, p1: Param(a), handler: fn(Req, a) -> Response(BitArray), ) -> Router { let raw = ipath.parse(pattern) validate_param(p1, raw, pattern) let segments = apply_param_type(raw, p1) let wrapped = fn(req: Req) -> Response(BitArray) { let assert Ok(v1) = dict.get(req.params, p1.name) |> result.try(p1.parse) handler(req, v1) } add_route_raw(router, method, segments, wrapped) } fn typed2( router: Router, method: Method, pattern: String, p1: Param(a), p2: Param(b), handler: fn(Req, a, b) -> Response(BitArray), ) -> Router { let raw = ipath.parse(pattern) validate_param(p1, raw, pattern) validate_param(p2, raw, pattern) let segments = raw |> apply_param_type(p1) |> apply_param_type(p2) let wrapped = fn(req: Req) -> Response(BitArray) { let assert Ok(v1) = dict.get(req.params, p1.name) |> result.try(p1.parse) let assert Ok(v2) = dict.get(req.params, p2.name) |> result.try(p2.parse) handler(req, v1, v2) } add_route_raw(router, method, segments, wrapped) } fn typed3( router: Router, method: Method, pattern: String, p1: Param(a), p2: Param(b), p3: Param(c), handler: fn(Req, a, b, c) -> Response(BitArray), ) -> Router { let raw = ipath.parse(pattern) validate_param(p1, raw, pattern) validate_param(p2, raw, pattern) validate_param(p3, raw, pattern) let segments = raw |> apply_param_type(p1) |> apply_param_type(p2) |> apply_param_type(p3) let wrapped = fn(req: Req) -> Response(BitArray) { let assert Ok(v1) = dict.get(req.params, p1.name) |> result.try(p1.parse) let assert Ok(v2) = dict.get(req.params, p2.name) |> result.try(p2.parse) let assert Ok(v3) = dict.get(req.params, p3.name) |> result.try(p3.parse) handler(req, v1, v2, v3) } add_route_raw(router, method, segments, wrapped) } // --------------------------------------------------------------------------- // Req — accessors // --------------------------------------------------------------------------- /// The request HTTP method. pub fn method(req: Req) -> Method { req.request.method } /// The request path (e.g. "/users/42"). pub fn req_path(req: Req) -> String { req.request.path } /// Get a request header by key (case-insensitive). pub fn header(req: Req, key: String) -> Result(String, Nil) { request.get_header(req.request, key) } /// All request headers. pub fn headers(req: Req) -> List(#(String, String)) { req.request.headers } /// The raw request body as BitArray. pub fn body(req: Req) -> BitArray { req.request.body } /// The request body decoded as UTF-8 text. pub fn text_body(req: Req) -> Result(String, Nil) { bit_array.to_string(req.request.body) } /// Extract a path parameter as String. /// /// ```gleam /// // pattern: "/users/:name" /// radiant.str_param(req, "name") // Ok("alice") /// ``` pub fn str_param(req: Req, name: String) -> Result(String, Nil) { dict.get(req.params, name) } /// Extract a path parameter and parse it as Int. /// /// ```gleam /// // pattern: "/users/:id" /// radiant.int_param(req, "id") // Ok(42) /// ``` pub fn int_param(req: Req, name: String) -> Result(Int, Nil) { dict.get(req.params, name) |> result.try(int.parse) } /// Get a single query parameter by key. pub fn query(req: Req, key: String) -> Result(String, Nil) { queries(req) |> list.key_find(key) } /// All query parameters as key-value pairs. pub fn queries(req: Req) -> List(#(String, String)) { case req.request.query { Some(q) -> case uri.parse_query(q) { Ok(pairs) -> pairs Error(Nil) -> [] } None -> [] } } /// Access the underlying `Request(BitArray)` for anything radiant doesn't wrap. pub fn original(req: Req) -> Request(BitArray) { req.request } /// Store a typed value in the request context. /// Use a `Key(a)` constant to guarantee type-safe retrieval. /// /// ```gleam /// pub const user_key: radiant.Key(User) = radiant.key("user") /// /// radiant.set_context(req, user_key, User(name: "Alice")) /// ``` pub fn set_context(req: Req, k: Key(a), value: a) -> Req { let Key(name) = k Req(..req, context: dict.insert(req.context, name, unsafe_coerce(value))) } /// Retrieve a typed value from the request context. /// Returns `Ok(a)` if the key was set, `Error(Nil)` otherwise. /// /// ```gleam /// let assert Ok(user) = radiant.get_context(req, user_key) /// ``` pub fn get_context(req: Req, k: Key(a)) -> Result(a, Nil) { let Key(name) = k case dict.get(req.context, name) { Ok(dyn) -> Ok(unsafe_coerce(dyn)) Error(_) -> Error(Nil) } } // Identity function used for type-erasing values into the context dict // and recovering them out. Safe as long as callers use Key(a) correctly. @external(erlang, "gleam_stdlib", "identity") @external(javascript, "../gleam_stdlib.mjs", "identity") fn unsafe_coerce(a: a) -> b // --------------------------------------------------------------------------- // Response helpers // --------------------------------------------------------------------------- /// Build a response with the given status and UTF-8 text body. pub fn response(status: Int, body_text: String) -> Response(BitArray) { Response(status:, headers: [], body: <>) } /// 200 OK with text body. pub fn ok(body_text: String) -> Response(BitArray) { response(200, body_text) } /// 201 Created with text body. pub fn created(body_text: String) -> Response(BitArray) { response(201, body_text) } /// 204 No Content (empty body). pub fn no_content() -> Response(BitArray) { response(204, "") } /// 404 Not Found (empty body). pub fn not_found() -> Response(BitArray) { response(404, "") } /// 400 Bad Request (empty body). pub fn bad_request() -> Response(BitArray) { response(400, "") } /// 401 Unauthorized (empty body). /// Set `www-authenticate` with `with_header` if needed. pub fn unauthorized() -> Response(BitArray) { response(401, "") } /// 403 Forbidden (empty body). pub fn forbidden() -> Response(BitArray) { response(403, "") } /// 405 Method Not Allowed with an `allow` header. pub fn method_not_allowed(allowed: String) -> Response(BitArray) { response(405, "") |> with_header("allow", allowed) } /// 422 Unprocessable Entity (empty body). /// Standard response for semantic validation failures (e.g. invalid field values). pub fn unprocessable_entity() -> Response(BitArray) { response(422, "") } /// 500 Internal Server Error (empty body). pub fn internal_server_error() -> Response(BitArray) { response(500, "") } /// 303 See Other redirect. pub fn redirect(uri: String) -> Response(BitArray) { Response(status: 303, headers: [#("location", uri)], body: <<>>) } /// 200 OK with `content-type: application/json`. pub fn json(body_text: String) -> Response(BitArray) { response(200, body_text) |> with_header("content-type", "application/json; charset=utf-8") } /// 200 OK with `content-type: text/html`. pub fn html(body_text: String) -> Response(BitArray) { response(200, body_text) |> with_header("content-type", "text/html; charset=utf-8") } /// Set a header on a response (key is lowercased automatically). pub fn with_header( resp: Response(BitArray), key: String, value: String, ) -> Response(BitArray) { response.set_header(resp, key, value) } // --------------------------------------------------------------------------- // Built-in middleware // --------------------------------------------------------------------------- /// Configuration for the CORS middleware. /// /// - `origins`: list of allowed origins, or `["*"]` for any origin /// - `methods`: list of allowed HTTP methods /// - `headers`: list of allowed request headers /// - `max_age`: how long (in seconds) browsers should cache preflight results pub type CorsConfig { CorsConfig( origins: List(String), methods: List(Method), headers: List(String), max_age: Int, ) } /// Sensible default CORS config. /// /// - Origins: `["*"]` (any) /// - Methods: GET, POST, PUT, PATCH, DELETE /// - Headers: `content-type`, `authorization` /// - Max age: 86400 seconds (24 hours) pub fn default_cors() -> CorsConfig { CorsConfig( origins: ["*"], methods: [Get, Post, Put, Patch, Delete], headers: ["content-type", "authorization"], max_age: 86_400, ) } /// CORS middleware. Handles preflight `OPTIONS` requests automatically and /// sets `access-control-allow-*` headers on all responses. /// /// ```gleam /// radiant.new() /// |> radiant.middleware(radiant.cors(radiant.default_cors())) /// ``` pub fn cors(config: CorsConfig) -> Middleware { fn(next) { fn(req) { let origin = header(req, "origin") |> result.unwrap("") let allowed = origin != "" && { list.contains(config.origins, "*") || list.contains(config.origins, origin) } case method(req) { http.Options -> case allowed { True -> no_content() |> with_header("access-control-allow-origin", origin) |> with_header( "access-control-allow-methods", config.methods |> list.map(http.method_to_string) |> string.join(", "), ) |> with_header( "access-control-allow-headers", string.join(config.headers, ", "), ) |> with_header( "access-control-max-age", int.to_string(config.max_age), ) False -> no_content() } _ -> { let resp = next(req) case allowed { True -> with_header(resp, "access-control-allow-origin", origin) False -> resp } } } } } } /// Logging middleware. Takes a logging function and calls it before and after /// each request with method, path, and status code. /// /// Works with any logger — woof, io.println, or your own: /// /// ```gleam /// // With io.println: /// radiant.log(io.println) /// /// // With woof: /// radiant.log(fn(msg) { woof.log(logger, woof.Info, msg, []) }) /// ``` pub fn log(logger: fn(String) -> a) -> Middleware { fn(next) { fn(req) { let m = http.method_to_string(method(req)) let p = req_path(req) logger(m <> " " <> p) let resp: Response(BitArray) = next(req) logger(m <> " " <> p <> " → " <> int.to_string(resp.status)) resp } } } /// Rescue middleware. Catches Erlang exceptions (panics) in handlers and /// returns a 500 response instead of crashing the process. /// /// The callback receives the exception for logging or error reporting. /// /// ```gleam /// radiant.new() /// |> radiant.middleware(radiant.rescue(fn(err) { /// io.debug(err) /// radiant.response(500, "Internal server error") /// })) /// ``` pub fn rescue( on_error: fn(exception.Exception) -> Response(BitArray), ) -> Middleware { fn(next) { fn(req) { case exception.rescue(fn() { next(req) }) { Ok(resp) -> resp Error(err) -> on_error(err) } } } } /// A middleware that parses the request body as JSON and stores it in the /// request context under the given typed key. /// /// If the body is not valid JSON or does not match the decoder, /// returns `400 Bad Request` immediately. /// /// ```gleam /// pub const user_key: radiant.Key(User) = radiant.key("user") /// /// let user_decoder = { /// use name <- decode.field("name", decode.string) /// decode.success(User(name)) /// } /// /// radiant.new() /// |> radiant.middleware(radiant.json_body(user_key, user_decoder)) /// |> radiant.post("/users", fn(req) { /// let assert Ok(user) = radiant.get_context(req, user_key) /// radiant.ok("Hello " <> user.name) /// }) /// ``` pub fn json_body(key: Key(a), decoder: decode.Decoder(a)) -> Middleware { fn(next) { fn(req) { // Skip parsing when the body is empty (e.g. GET, HEAD, DELETE requests). // The handler will receive Error(Nil) from get_context if it needs the value. case body(req) { <<>> -> next(req) _ -> case text_body(req) { Ok(b) -> case gjson.parse(b, decoder) { Ok(val) -> next(set_context(req, key, val)) Error(_) -> bad_request() } Error(_) -> bad_request() } } } } } /// A middleware that serves static files from a directory. /// /// It strips the `prefix` from the request path and looks for the remaining /// path inside the `directory`. /// /// If a file is found, it's served with a guessed Mime-Type. /// Otherwise, it falls back to the next handler (usualy a 404 fallback). pub fn serve_static( prefix prefix: String, from directory: String, via fs: FileSystem, ) -> Middleware { let prefix = case string.starts_with(prefix, "/") { True -> prefix False -> "/" <> prefix } fn(next) { fn(req) { let path = req_path(req) case string.starts_with(path, prefix) { True -> { let rel_path = string.drop_start(path, string.length(prefix)) |> string.split("/") |> list.filter(fn(s) { s != "" && s != ".." }) |> string.join("/") let full_path = case directory { "." -> rel_path _ -> directory <> "/" <> rel_path } case fs.is_file(full_path) { True -> { case fs.read_bits(full_path) { Ok(bits) -> { let mime = mime_from_path(full_path) response.new(200) |> response.set_body(bits) |> response.set_header("content-type", mime) } Error(_) -> next(req) } } False -> next(req) } } False -> next(req) } } } } fn mime_from_path(path: String) -> String { let ext = path |> string.split(".") |> list.last() |> result.unwrap("") |> string.lowercase() case ext { "html" | "htm" -> "text/html" "css" -> "text/css" "js" -> "application/javascript" "json" -> "application/json" "png" -> "image/png" "jpg" | "jpeg" -> "image/jpeg" "gif" -> "image/gif" "svg" -> "image/svg+xml" "txt" -> "text/plain" _ -> "application/octet-stream" } } // --------------------------------------------------------------------------- // Testing helpers // --------------------------------------------------------------------------- /// Create a test request with the given method and path. /// Supports query strings: `test_request(Get, "/search?q=gleam")`. /// /// Note: import `gleam/http.{Get, Post, ...}` for method constructors. pub fn test_request(method: Method, raw_path: String) -> Request(BitArray) { case string.split_once(raw_path, on: "?") { Ok(#(p, q)) -> request.new() |> request.set_method(method) |> request.set_path(p) |> fn(r) { Request(..r, query: Some(q)) } |> request.set_body(<<>>) Error(Nil) -> request.new() |> request.set_method(method) |> request.set_path(raw_path) |> request.set_body(<<>>) } } /// Shortcut: create a GET test request. pub fn test_get(raw_path: String) -> Request(BitArray) { test_request(Get, raw_path) } /// Shortcut: create a POST test request with a UTF-8 body. pub fn test_post(raw_path: String, body_text: String) -> Request(BitArray) { test_request(Post, raw_path) |> request.set_body(<>) } /// Shortcut: create a PUT test request with a UTF-8 body. pub fn test_put(raw_path: String, body_text: String) -> Request(BitArray) { test_request(Put, raw_path) |> request.set_body(<>) } /// Shortcut: create a PATCH test request with a UTF-8 body. pub fn test_patch(raw_path: String, body_text: String) -> Request(BitArray) { test_request(Patch, raw_path) |> request.set_body(<>) } /// Shortcut: create a DELETE test request. pub fn test_delete(raw_path: String) -> Request(BitArray) { test_request(Delete, raw_path) } /// Shortcut: create a HEAD test request. pub fn test_head(raw_path: String) -> Request(BitArray) { test_request(http.Head, raw_path) } /// Shortcut: create an OPTIONS test request. pub fn test_options(raw_path: String) -> Request(BitArray) { test_request(http.Options, raw_path) } // --------------------------------------------------------------------------- // Assertions (Testing) // --------------------------------------------------------------------------- /// Assert that a response has the expected status code. /// Panics if the status doesn't match. pub fn should_have_status( resp: Response(BitArray), expected: Int, ) -> Response(BitArray) { case resp.status == expected { True -> resp False -> { let msg = "Radiant Test: Expected status " <> int.to_string(expected) <> ", got " <> int.to_string(resp.status) panic as msg } } } /// Assert that a response has the expected body. /// Panics if the body doesn't match. pub fn should_have_body( resp: Response(BitArray), expected: String, ) -> Response(BitArray) { let expected_bits = <> case resp.body == expected_bits { True -> resp False -> { let actual = bit_array.to_string(resp.body) |> result.unwrap("") panic as { "Radiant Test: Expected body \"" <> expected <> "\", got \"" <> actual <> "\"" } } } } /// Assert that a response has the expected header value. /// Panics if the header is missing or doesn't match. pub fn should_have_header( resp: Response(BitArray), name: String, expected: String, ) -> Response(BitArray) { case response.get_header(resp, name) { Ok(val) if val == expected -> resp Ok(val) -> { panic as { "Radiant Test: Expected header '" <> name <> "' to be '" <> expected <> "', got '" <> val <> "'" } } Error(Nil) -> { panic as { "Radiant Test: Missing expected header '" <> name <> "'" } } } } /// Parse the response body as JSON and verify it matches the decoder. /// Returns the decoded value for further assertions. /// Panics if parsing fails. pub fn should_have_json_body( resp: Response(BitArray), decoder: decode.Decoder(a), ) -> a { let body = case bit_array.to_string(resp.body) { Ok(s) -> s Error(_) -> panic as "Radiant Test: Response body is not valid UTF-8" } case gjson.parse(body, decoder) { Ok(val) -> val Error(_) -> panic as "Radiant Test: Failed to decode JSON response body" } }