///|
/// An incoming HTTP request containing the method, URL, headers, and body.
pub(all) struct HttpRequest {
/// The HTTP method (Get, Post, Put, etc.).
http_method : HttpMethod
/// The raw request-target string (path + query + fragment).
url : String
/// Request headers as key-value pairs.
headers : Map[String, String]
/// The raw request body bytes.
mut raw_body : Bytes
/// Lazily cached parsed path, populated on first call to `path()`.
priv mut cached_path : String?
/// Lazily cached parsed query params, populated on first call to `get_query()` or `query_params()`.
priv mut cached_query_params : Map[String, String]?
} derive(Debug(ignore=Bytes))
///|
/// Creates a new `HttpRequest` with the given method, URL, headers, and optional body.
pub fn HttpRequest::HttpRequest(
http_method : HttpMethod,
url : String,
headers : Map[String, String],
raw_body? : Bytes,
) -> HttpRequest {
{
http_method,
url,
headers,
raw_body: raw_body.unwrap_or(b""),
cached_path: None,
cached_query_params: None,
}
}
///|
/// Creates a new `HttpRequest` from a method string, parsing it into an `HttpMethod`.
pub fn HttpRequest::from_method_string(
http_method : String,
url : String,
headers : Map[String, String],
raw_body : Bytes,
) -> HttpRequest {
HttpRequest(HttpMethod::from_string(http_method), url, headers, raw_body~)
}
///|
/// Returns the HTTP method as a string (e.g., "GET", "POST").
pub fn HttpRequest::method_string(self : HttpRequest) -> String {
self.http_method.to_method_string()
}
///|
/// Trait for types that can be deserialized from an HTTP request body.
pub(open) trait BodyReader {
fn from_request(req : HttpRequest) -> Self raise
}
///|
/// Returns the path component of the request URL, excluding query string and fragment.
pub fn HttpRequest::path(self : HttpRequest) -> String {
match self.cached_path {
Some(p) => p
None => {
let p = @httputil.request_target_path(self.url)
self.cached_path = Some(p)
p
}
}
}
///|
/// Returns the raw query string from the request URL, or `None` if absent.
pub fn HttpRequest::query_string(self : HttpRequest) -> String? {
@httputil.request_target_query_string(self.url)
}
///|
/// Returns the Content-Type header value, or None if not set.
pub fn HttpRequest::content_type(self : HttpRequest) -> String? {
self.get_header("content-type")
}
///|
/// Retrieves a cookie by name from the request's Cookie header, if present.
pub fn HttpRequest::get_cookie(
self : HttpRequest,
name : String,
) -> @cookie.CookieItem? {
match self.get_header("cookie") {
Some(cookie) => @cookie.parse_cookie(cookie).get(name)
None => None
}
}
/// Returns the cached parsed query params, parsing the URL on first access.
/// Private — external callers should use `query_params()` or `get_query()`
/// which return owned copies.
///|
fn HttpRequest::cached_query(self : HttpRequest) -> Map[String, String] {
match self.cached_query_params {
Some(params) => params
None => {
let params : Map[String, String] = match self.query_string() {
Some(query) => @httputil.parse_form_data_str(query[:])
None => {}
}
self.cached_query_params = Some(params)
params
}
}
}
///|
/// Parses the query string into a map of key-value pairs.
///
/// Returns a fresh copy on every call — mutating the returned map does NOT
/// affect subsequent `get_query()` calls. The underlying parse is cached, so
/// calling this multiple times only parses the URL once.
pub fn HttpRequest::query_params(self : HttpRequest) -> Map[String, String] {
let cached = self.cached_query()
let copy : Map[String, String] = Map([])
cached.each((k, v) => copy[k] = v)
copy
}
///|
/// Looks up a single query parameter by key, returning `None` if not found.
/// Uses the cached query params — calling this multiple times with different
/// keys only parses the URL once.
pub fn HttpRequest::get_query(self : HttpRequest, key : String) -> String? {
self.cached_query().get(key)
}
///|
/// Deserializes the request body into a value of type `T` via the `BodyReader` trait.
pub fn[T : BodyReader] HttpRequest::body(self : HttpRequest) -> T raise {
T::from_request(self)
}
///|
/// Parses the request body as JSON and deserializes into type `T`.
pub fn[T : FromJson] HttpRequest::json(self : HttpRequest) -> T raise {
let text = @utf8.decode(self.raw_body)
let json = @json.parse(text)
@json.from_json(json)
}
///|
/// Tries to parse the request body as JSON, returning `Ok(T)` on success
/// or `Err(message)` on failure.
pub fn[T : FromJson] HttpRequest::try_json(
self : HttpRequest,
) -> Result[T, String] {
try {
let text = @utf8.decode(self.raw_body)
let json = @json.parse(text)
Ok(@json.from_json(json))
} catch {
err => Err(err.to_string())
}
}
///|
test "path is cached after first access" {
let req = HttpRequest(Get, "/api/users?q=test", {}, raw_body=b"")
assert_eq(req.cached_path, None)
let path1 = req.path()
assert_eq(path1, "/api/users")
assert_eq(req.cached_path, Some("/api/users"))
// Second call returns cached value
let path2 = req.path()
assert_eq(path1, path2)
}
///|
test "query_params returns a copy that doesn't affect the cache" {
let req = HttpRequest(Get, "/search?q=hello&lang=en", {}, raw_body=b"")
let params1 = req.query_params()
params1["injected"] = "hacked"
// The mutation should not affect subsequent gets
assert_eq(req.get_query("injected"), None)
let params2 = req.query_params()
assert_eq(params2.get("injected"), None)
}
///|
test "query_params is cached after first access" {
let req = HttpRequest(Get, "/search?q=hello&lang=en", {}, raw_body=b"")
assert_eq(req.cached_query_params, None)
let params1 = req.query_params()
assert_eq(params1.get("q"), Some("hello"))
assert_true(req.cached_query_params is Some(_))
// get_query uses the cached params
assert_eq(req.get_query("lang"), Some("en"))
assert_eq(req.get_query("missing"), None)
}
///|
test "get_header is case-insensitive" {
let req = HttpRequest(
Get,
"/",
{ "X-Test": "value", "content-type": "text/plain" },
raw_body=b"",
)
assert_eq(req.get_header("x-test"), Some("value"))
assert_eq(req.get_header("X-TEST"), Some("value"))
assert_eq(req.get_header("Content-Type"), Some("text/plain"))
assert_eq(req.get_header("missing"), None)
}
///|
test "request target helpers expose path and query" {
let req = HttpRequest(
Get,
"/search/moonbit?q=async%20native&lang=en#frag",
{},
raw_body=b"",
)
assert_eq(req.path(), "/search/moonbit")
assert_eq(req.query_string(), Some("q=async%20native&lang=en"))
assert_eq(req.get_query("q"), Some("async native"))
assert_eq(req.get_query("lang"), Some("en"))
assert_eq(req.get_query("missing"), None)
}
///|
pub impl BodyReader for String with fn from_request(req : HttpRequest) -> String raise {
// Strict UTF-8 decoding — raises on invalid bytes.
// For UTF-16 payloads, decode manually via a dedicated helper.
@utf8.decode(req.raw_body)
}
///|
pub impl BodyReader for Json with fn from_request(req : HttpRequest) -> Json raise {
@json.parse(@utf8.decode(req.raw_body))
}
///|
pub impl BodyReader for Bytes with fn from_request(req : HttpRequest) -> Bytes raise {
req.raw_body
}
///|
pub impl BodyReader for FixedArray[Byte] with fn from_request(req : HttpRequest) -> FixedArray[
Byte,
] raise {
req.raw_body.to_fixedarray()
}
///|
pub impl BodyReader for Array[Byte] with fn from_request(req : HttpRequest) -> Array[
Byte,
] raise {
req.raw_body.to_array()
}
///|
test "read_body" {
let req = HttpRequest(Post, "/", Map([]), raw_body=b"{\"Hello\":\"World!\"}")
let text : String = req.body()
let json : Json = req.body()
debug_inspect(
text,
content=(
#|"{\"Hello\":\"World!\"}"
),
)
json_inspect(json, content={ "Hello": "World!" })
}
///|
#warnings("-unnecessary_annotation")
struct TryJsonTestUser {
name : String
age : Int
} derive(FromJson, ToJson, Eq, Debug)
///|
test "try_json returns Ok on valid json" {
let req = HttpRequest(
Post,
"/",
{},
raw_body=@utf8.encode("{\"name\":\"Alice\",\"age\":30}"),
)
let result : Result[TryJsonTestUser, String] = req.try_json()
assert_eq(result, Ok({ name: "Alice", age: 30 }))
}
///|
test "try_json returns Err on invalid json" {
let req = HttpRequest(Post, "/", {}, raw_body=b"not valid json")
let result : Result[TryJsonTestUser, String] = req.try_json()
assert_true(result is Err(_))
}
///|
test "try_json returns Err on wrong schema" {
let req = HttpRequest(
Post,
"/",
{},
raw_body=@utf8.encode("{\"wrong_field\":123}"),
)
let result : Result[TryJsonTestUser, String] = req.try_json()
assert_true(result is Err(_))
}