///|
/// An incoming HTTP request containing the method, URL, headers, and body.
pub(all) struct HttpRequest {
http_method : HttpMethod
url : String
headers : Map[String, String]
mut raw_body : Bytes
// Lazily cached parsed path and query params to avoid re-parsing per request
priv mut cached_path : String?
priv mut cached_query_params : Map[String, String]?
/// Constructor — creates a new HttpRequest.
fn new(
http_method : HttpMethod,
url : String,
headers : Map[String, String],
raw_body? : Bytes,
) -> HttpRequest
}
///|
/// Creates a new `HttpRequest` with the given method, URL, headers, and optional body.
pub fn HttpRequest::new(
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 {
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 = @mhttp.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? {
@mhttp.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")
}
/// 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) => @mhttp.parse_form_data(@utf8.encode(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] = {}
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 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 from_request(req : HttpRequest) -> Json raise {
@json.parse(@utf8.decode(req.raw_body))
}
///|
pub impl BodyReader for Bytes with from_request(req : HttpRequest) -> Bytes raise {
req.raw_body
}
///|
pub impl BodyReader for FixedArray[Byte] with from_request(req : HttpRequest) -> FixedArray[
Byte,
] raise {
req.raw_body.to_fixedarray()
}
///|
pub impl BodyReader for Array[Byte] with from_request(req : HttpRequest) -> Array[
Byte,
] raise {
req.raw_body.to_array()
}
///|
test "read_body" {
let req = HttpRequest(
Post,
"/",
Map::new(),
raw_body=b"{\"Hello\":\"World!\"}",
)
let text : String = req.body()
let json : Json = req.body()
inspect(
text,
content=(
#|{"Hello":"World!"}
),
)
json_inspect(json, content={ "Hello": "World!" })
}
///|
#warnings("-unnecessary_annotation")
struct TryJsonTestUser {
name : String
age : Int
} derive(FromJson, ToJson, Eq)
///|
impl Show for TryJsonTestUser with output(self, logger) {
logger.write_string(
"TryJsonTestUser { name: \{self.name}, age: \{self.age} }",
)
}
///|
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(_))
}