README

#Core

The foundational HTTP types for Crescent: requests, responses, status codes, methods, and the Responder trait that ties them together.

This package is a pure data layer -- it defines the types that flow through the framework but performs no I/O, no routing, and no async work. That makes it safe to import from any context: server handlers, middleware, fetch clients, test assertions, or standalone scripts that construct HTTP values for serialization.

The main bobzhang/crescent package re-exports everything in this package, so application code can use either @crescent.HttpResponse or @core.HttpResponse interchangeably. Sub-packages that want a lighter dependency (no App, no server runtime) can import bobzhang/crescent/core directly.

This package provides:

  • HttpRequest -- an incoming HTTP request (method, URL, headers, body) with helpers for path extraction, query parsing, cookie reading, and typed body deserialization.
  • HttpResponse -- an outgoing HTTP response (status code, headers, cookies, body) with a fluent builder API and factory methods for common status codes.
  • StatusCode -- a comprehensive enum covering every IANA-registered HTTP status code, plus a Custom(Int) escape hatch.
  • HttpMethod -- an enum for standard HTTP methods (GET, POST, PUT, etc.) with string round-tripping.
  • Responder -- a trait that any type can implement to become a valid response body. Built-in implementations for String, Json, Bytes, HttpResponse, HttpRequest, and Html.
  • BodyReader -- a trait for deserializing request bodies into typed values.

#Install

This package is included with bobzhang/crescent. To use it directly for a lighter dependency:

import { "bobzhang/crescent/core" ... }

#HttpRequest

An HttpRequest bundles the HTTP method, URL, headers, and raw body bytes. The framework constructs these from incoming TCP connections, but you can also create them directly for testing or for building outgoing requests with @fetch.request.

///|
test "construct and inspect a request" {
let req = @core.HttpRequest(
Get,
"/users?page=2",
{ "Authorization": "Bearer token" },
raw_body=b"",
)
debug_inspect(
req,
content=(
#|{
#| http_method: Get,
#| url: "/users?page=2",
#| headers: { "Authorization": "Bearer token" },
#| raw_body: ...,
#| cached_path: None,
#| cached_query_params: None,
#|}
),
)
assert_eq(req.path(), "/users")
assert_eq(req.query_string(), Some("page=2"))
assert_eq(req.get_query("page"), Some("2"))
assert_eq(req.get_header("authorization"), Some("Bearer token"))
}

#Path and query parsing

path() extracts the URL path, stripping the query string and fragment. query_string() returns the raw query portion. get_query(key) does a decoded key lookup. All three cache their results so repeated calls are free.

///|
test "query parameters are percent-decoded" {
let req = @core.HttpRequest(
Get,
"/search?q=hello%20world&lang=en",
{},
raw_body=b"",
)
|> x => { (x.get_query("q"), x.get_query("lang"), x.get_query("missing")) }
debug_inspect(
req,
content=(
#|(Some("hello world"), Some("en"), None)
),
)
}

#Typed body reading

req.body[T]() deserializes the raw bytes into any type that implements the BodyReader trait. Built-in readers exist for String, Json, Bytes, FixedArray[Byte], and Array[Byte]:

///|
test "read body as string" {
let req = @core.HttpRequest(Post, "/", {}, raw_body=b"hello")
let text : String = req.body()
assert_eq(text, "hello")
}

///|
test "read body as JSON" {
let req = @core.HttpRequest(Post, "/", {}, raw_body=b"{\"name\":\"Alice\"}")
let body : Json = req.body()
debug_inspect(
body,
content=(
#|Object({ "name": String("Alice") })
),
)
}

#JSON deserialization shorthand

req.json[T]() parses the body as JSON and deserializes into any FromJson type in one step:

///|
#warnings("-unnecessary_annotation")
struct CoreDocUser {
name : String
age : Int
} derive(FromJson, Eq, Debug)

///|
test "json shorthand parses typed value" {
let req = @core.HttpRequest(
Post,
"/",
{},
raw_body=b"{\"name\":\"Bob\",\"age\":30}",
)
let user : CoreDocUser = req.json()
debug_inspect(
user,
content=(
#|{ name: "Bob", age: 30 }
),
)
}

get_cookie(name) parses the Cookie header (case-insensitive lookup) and returns the matching CookieItem, or None if absent:

///|
test "read a cookie from the request" {
let req = @core.HttpRequest(
Get,
"/",
{ "Cookie": "session=abc123; theme=dark" },
raw_body=b"",
)
guard req.get_cookie("session") is Some(c) else {
fail("expected session cookie")
}
assert_eq(c.value, "abc123")
assert_eq(req.get_cookie("missing"), None)
}

#HttpResponse

An HttpResponse carries a status code, headers, cookies, and a body. Build one with the constructor or the named factory methods, then chain .body(), .header(), .json(), or .json_value() to finalize it.

#Factory methods

///|
test "factory methods set the right status code" {
assert_eq(@core.HttpResponse::ok().status_code, OK)
assert_eq(@core.HttpResponse::created().status_code, Created)
assert_eq(@core.HttpResponse::not_found().status_code, NotFound)
assert_eq(@core.HttpResponse::bad_request().status_code, BadRequest)
assert_eq(
@core.HttpResponse::internal_server_error().status_code,
InternalServerError,
)
}

#Fluent builder

///|
test "fluent response building" {
let res = @core.HttpResponse::ok()
.header("X-Request-Id", "abc-123")
.body(@core.html("<h1>Hello</h1>"))
assert_eq(res.status_code, OK)
debug_inspect(
res.headers.get("Content-Type"),
content="Some(\"text/html; charset=utf-8\")",
)
debug_inspect(res.headers.get("X-Request-Id"), content="Some(\"abc-123\")")
}

#JSON responses

json() takes any &ToJson and json_value() takes a concrete T : ToJson. Both set Content-Type: application/json; charset=utf-8:

///|
test "json response sets content type and body" {
let res = @core.HttpResponse::ok().json(({ "status": "ok" } : Json))
debug_inspect(
res.headers.get("Content-Type"),
content="Some(\"application/json; charset=utf-8\")",
)
}

#Redirects

///|
test "redirect helpers" {
let r301 = @core.HttpResponse::redirect("/new")
debug_inspect(
r301,
content=(
#|{
#| status_code: MovedPermanently,
#| headers: { "Location": "/new" },
#| cookies: {},
#| raw_body: ...,
#|}
),
)

let r302 = @core.HttpResponse::redirect_temporary("/temp")
assert_eq(r302.status_code, Found)

let r307 = @core.HttpResponse::redirect_307("/keep-method")
assert_eq(r307.status_code, TemporaryRedirect)
}

#Cookies

set_cookie and delete_cookie manage response cookies with full attribute support. The framework serializes them into Set-Cookie headers when sending the response:

///|
test "set and delete cookies" {
let res = @core.HttpResponse::ok()
res.set_cookie("session", "xyz", path="/", http_only=true, secure=true)
guard res.cookies.get("session") is Some(c) else {
fail("expected session cookie")
}
assert_eq(c.value, "xyz")
assert_eq(c.path, Some("/"))
assert_eq(c.http_only, Some(true))

// Deleting sets Max-Age=0
res.delete_cookie("session")
guard res.cookies.get("session") is Some(deleted) else {
fail("expected deleted cookie")
}
assert_eq(deleted.max_age, Some(0))
}

#Error responses

HttpResponse::error(status, message) creates a JSON error body with the status code and message, suitable for API error responses:

///|
test "error helper creates structured JSON body" {
let res = @core.HttpResponse::error(BadRequest, "name is required")
assert_eq(res.status_code, BadRequest)
let body : String = res.read_body()
assert_true(body.contains("name is required"))
assert_true(body.contains("400"))
}

#StatusCode

A comprehensive enum covering every IANA-registered status code. Pattern match on it directly -- no need to compare raw integers:

///|
test "status code round-trips through integer" {
assert_eq(@core.StatusCode::from_int(200), OK)
assert_eq(@core.StatusCode::from_int(404), NotFound)
let ok : @core.StatusCode = OK
assert_eq(ok.to_int(), 200)
// Unknown codes become Custom
assert_eq(@core.StatusCode::from_int(599), Custom(599))
}

#HttpMethod

///|
test "method round-trips through string" {
assert_eq(@core.HttpMethod::from_string("GET"), Get)
let get : @core.HttpMethod = Get
assert_eq(get.to_method_string(), "GET")
// Unknown methods use Other
let custom = @core.HttpMethod::from_string("PURGE")
assert_eq(custom.to_method_string(), "PURGE")
}

#Traits

This package defines two open traits that drive polymorphic request and response handling: Responder (any value → HTTP response body) and BodyReader (request/response bytes → typed value). Both are pub(open), so user code is free to add new implementations.

#trait Responder

///|
pub(open) trait Responder {
fn options(Self, HttpResponse) -> Unit
fn output(Self, Buffer) -> Unit
fn output_bytes(Self) -> Bytes?
}

The Responder trait is the adapter between any MoonBit value and an HTTP response body. Handlers return &Responder, and the framework calls three methods on it:

MethodPurpose
options(res)Set status code and headers (e.g. Content-Type) on the response
output(buf)Write the body bytes into a buffer
output_bytes()Return pre-encoded bytes directly (fast path, avoids a copy)

Built-in implementations:

TypeContent-Type
String / StringViewtext/plain; charset=utf-8
Json / &ToJsonapplication/json; charset=utf-8
Bytesapplication/octet-stream
HttpResponseCopies status, merges headers, forwards body
HttpRequestMerges headers, forwards body (useful for proxying)
Html (via html())text/html; charset=utf-8

#String as a responder

The simplest handler just returns a string -- Crescent wraps it in the Responder impl that sets text/plain:

///|
test "string responder sets text/plain" {
let res = @core.HttpResponse(status_code=OK)
let responder : &@core.Responder = "hello"
responder.options(res)
debug_inspect(
res.headers.get("Content-Type"),
content="Some(\"text/plain; charset=utf-8\")",
)
}

#html() and text() helpers

html() creates an Html responder that sets text/html; charset=utf-8. text() creates a plain-text responder. Both accept any &Show value:

///|
test "html helper sets text/html content type" {
let res = @core.HttpResponse(status_code=OK)
let responder = @core.html("<h1>Hello</h1>")
responder.options(res)
debug_inspect(
res.headers.get("Content-Type"),
content="Some(\"text/html; charset=utf-8\")",
)
let buf = Buffer()
responder.output(buf)
assert_eq(buf.contents(), @utf8.encode("<h1>Hello</h1>"))
}

#trait BodyReader

///|
pub(open) trait BodyReader {
fn from_request(HttpRequest) -> Self raise
}

The BodyReader trait powers typed body deserialization for both requests and responses. Implement it for your own types to enable req.body[MyType]() and res.read_body[MyType]():

TypeBehavior
StringUTF-8 decode (raises on invalid bytes)
JsonParse as JSON value
BytesReturn raw bytes as-is
FixedArray[Byte]Convert to fixed-size byte array
Array[Byte]Convert to resizable byte array

///|
test "read response body as typed value" {
let res = @core.HttpResponse(status_code=OK, raw_body=b"hello")
let text : String = res.read_body()
assert_eq(text, "hello")
}

#
BodyReader

pub(open) trait BodyReader {
fn from_request(req : HttpRequest) -> Self raise
}

Trait for types that can be deserialized from an HTTP request body.
impl BodyReader for Bytes
impl BodyReader for Json

#
Responder

pub(open) trait Responder {
fn options(Self, res : HttpResponse) -> Unit
fn output(Self, buf :
Buffer
) -> Unit
fn output_bytes(Self) -> Bytes?
}

Trait for types that can be sent as an HTTP response body.
impl Responder for String
impl Responder for Bytes
impl Responder for Json
impl Responder for ToJson

#
Html

type Html

impl Responder for Html

#
HttpMethod

pub(all) enum HttpMethod {
Get
Head
Post
Put
Patch
Delete
Options
Trace
Connect
Other(String)
} derive(Eq,
Debug
)

HTTP request methods as a type-safe enum.

Using this enum instead of raw strings prevents typos like "DLETE" or "OPTONS" and enables pattern matching in handlers.
impl Show for HttpMethod

#
HttpMethod::from_string

fn HttpMethod::from_string(s : String) -> HttpMethod

Parses a string into an HttpMethod.

#
HttpMethod::to_method_string

fn HttpMethod::to_method_string(self : HttpMethod) -> String

Converts the enum to its HTTP method string representation.

#
HttpRequest

pub(all) struct HttpRequest {
http_method : HttpMethod
url : String
headers : Map[String, String]
raw_body : Bytes
// private fields
} derive(
Debug
)

An incoming HTTP request containing the method, URL, headers, and body.

#
HttpRequest::HttpRequest

fn HttpRequest::HttpRequest(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.

#
HttpRequest::body

fn[T : BodyReader] HttpRequest::body(self : HttpRequest) -> T raise

Deserializes the request body into a value of type T via the BodyReader trait.

#
HttpRequest::content_type

fn HttpRequest::content_type(self : HttpRequest) -> String?

Returns the Content-Type header value, or None if not set.

#
HttpRequest::from_method_string

fn HttpRequest::from_method_string(http_method : String, url : String, headers : Map[String, String], raw_body : Bytes) -> HttpRequest

Creates a new HttpRequest from a method string, parsing it into an HttpMethod.

Retrieves a cookie by name from the request's Cookie header, if present.

#
HttpRequest::get_header

fn HttpRequest::get_header(self : HttpRequest, name : String) -> String?

Returns the value of a request header by name (case-insensitive), or None if not found.

#
HttpRequest::get_query

fn HttpRequest::get_query(self : HttpRequest, key : String) -> String?

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.

#
HttpRequest::json

Parses the request body as JSON and deserializes into type T.

#
HttpRequest::method_string

fn HttpRequest::method_string(self : HttpRequest) -> String

Returns the HTTP method as a string (e.g., "GET", "POST").

#
HttpRequest::path

fn HttpRequest::path(self : HttpRequest) -> String

Returns the path component of the request URL, excluding query string and fragment.

#
HttpRequest::query_params

fn HttpRequest::query_params(self : HttpRequest) -> Map[String, String]

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.

#
HttpRequest::query_string

fn HttpRequest::query_string(self : HttpRequest) -> String?

Returns the raw query string from the request URL, or None if absent.

#
HttpRequest::try_json

fn[T :
FromJson
] HttpRequest::try_json(self : HttpRequest) -> Result[T, String]

Tries to parse the request body as JSON, returning Ok(T) on success or Err(message) on failure.

#
HttpResponse

pub(all) struct HttpResponse {
status_code : StatusCode
headers : Map[String, String]
cookies : Map[String,
CookieItem
]
raw_body : Bytes
} derive(
Debug
)

An outgoing HTTP response containing the status code, headers, cookies, and body.

#
HttpResponse::HttpResponse

fn HttpResponse::HttpResponse(status_code~ : StatusCode, headers? : Map[String, String], cookies? : Map[String,
CookieItem
], raw_body? : Bytes) -> HttpResponse

Creates a new HttpResponse with the given status code and optional headers, cookies, and body.

#
HttpResponse::bad_request

fn HttpResponse::bad_request() -> HttpResponse

Creates a 400 Bad Request response.

#
HttpResponse::body

fn HttpResponse::body(self : HttpResponse, body : &Responder) -> HttpResponse

Sets the response body from any Responder and returns the response for chaining.

Applies the responder's options() (e.g. sets Content-Type) and uses the output_bytes() fast path when available to avoid an intermediate buffer copy.

#
HttpResponse::created

fn HttpResponse::created() -> HttpResponse

Creates a 201 Created response.
fn HttpResponse::delete_cookie(self : HttpResponse, key : String) -> Unit

Deletes a cookie by setting its value to empty and max-age to 0.

#
HttpResponse::error

fn HttpResponse::error(status : StatusCode, message : String) -> HttpResponse

Creates an error response with a JSON body containing status and message.

#
HttpResponse::forbidden

fn HttpResponse::forbidden() -> HttpResponse

Creates a 403 Forbidden response.

#
HttpResponse::header

fn HttpResponse::header(self : HttpResponse, name : String, value : String) -> HttpResponse

Sets a response header and returns self for fluent chaining.

Uses case-insensitive matching: setting Content-Type will replace any existing content-type or CONTENT-TYPE header.

#
HttpResponse::internal_server_error

fn HttpResponse::internal_server_error() -> HttpResponse

Creates a 500 Internal Server Error response.

#
HttpResponse::json

fn HttpResponse::json(self : HttpResponse, obj : &ToJson) -> HttpResponse

Sets the response body to the JSON representation of the given value. Also sets Content-Type: application/json; charset=utf-8.

#
HttpResponse::json_value

fn[T : ToJson] HttpResponse::json_value(self : HttpResponse, value : T) -> HttpResponse

Serializes a typed value as JSON, sets Content-Type, and returns the response.

Uses case-insensitive header matching: any existing content-type / CONTENT-TYPE header is replaced, not duplicated.

#
HttpResponse::no_content

fn HttpResponse::no_content() -> HttpResponse

Creates a 204 No Content response.

#
HttpResponse::not_found

fn HttpResponse::not_found() -> HttpResponse

Creates a 404 Not Found response.

#
HttpResponse::ok

Creates a 200 OK response.

#
HttpResponse::read_body

fn[T : BodyReader] HttpResponse::read_body(self : HttpResponse) -> T raise

Deserializes the response body into a value of type T via the BodyReader trait.

#
HttpResponse::redirect

fn HttpResponse::redirect(location : String) -> HttpResponse

Returns a 301 Moved Permanently redirect response.

#
HttpResponse::redirect_307

fn HttpResponse::redirect_307(location : String) -> HttpResponse

Returns a 307 Temporary Redirect response (preserves method).

#
HttpResponse::redirect_308

fn HttpResponse::redirect_308(location : String) -> HttpResponse

Returns a 308 Permanent Redirect response (preserves method).

#
HttpResponse::redirect_temporary

fn HttpResponse::redirect_temporary(location : String) -> HttpResponse

Returns a 302 Found (temporary) redirect response.
fn HttpResponse::set_cookie(self : HttpResponse, name : String, value : String, max_age? : Int, path? : String, domain? : String, secure? : Bool, http_only? : Bool, same_site? :
SameSiteOption
) -> Unit

Sets a cookie on the response with the given name, value, and optional attributes.

#
HttpResponse::to_responder

fn HttpResponse::to_responder(self : HttpResponse) -> &Responder

Converts this response into a Responder trait object for use as a handler return value.

#
HttpResponse::unauthorized

fn HttpResponse::unauthorized() -> HttpResponse

Creates a 401 Unauthorized response.

#
StatusCode

pub(all) enum StatusCode {
Continue
SwitchingProtocols
Processing
EarlyHints
OK
Created
Accepted
NonAuthoritativeInfo
NoContent
ResetContent
PartialContent
MultiStatus
AlreadyReported
IMUsed
MultipleChoices
MovedPermanently
Found
SeeOther
NotModified
UseProxy
TemporaryRedirect
PermanentRedirect
BadRequest
Unauthorized
PaymentRequired
Forbidden
NotFound
MethodNotAllowed
NotAcceptable
ProxyAuthRequired
RequestTimeout
Conflict
Gone
LengthRequired
PreconditionFailed
RequestEntityTooLarge
RequestUriTooLong
UnsupportedMediaType
RequestedRangeNotSatisfiable
ExpectationFailed
Teapot
MisdirectedRequest
UnprocessableEntity
Locked
FailedDependency
TooEarly
UpgradeRequired
PreconditionRequired
TooManyRequests
RequestHeaderFieldsTooLarge
UnavailableForLegalReasons
InternalServerError
NotImplemented
BadGateway
ServiceUnavailable
GatewayTimeout
HttpVersionNotSupported
VariantAlsoNegotiates
InsufficientStorage
LoopDetected
NotExtended
NetworkAuthenticationRequired
Custom(Int)
} derive(Eq, ToJson,
Debug
)

HTTP status codes as registered with IANA. See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
impl Show for StatusCode

#
StatusCode::from_int

fn StatusCode::from_int(i : Int) -> StatusCode

Converts an integer HTTP status code to a StatusCode enum value.

#
StatusCode::to_int

fn StatusCode::to_int(self : StatusCode) -> Int

Returns the integer value of this HTTP status code.

#
html

fn html(html : &Show) -> &Responder

Creates an HTML responder from any Show value, setting the Content-Type to text/html.

#
text

fn text(text : &Show) -> &Responder

Creates a plain-text responder from any Show value, setting the Content-Type to text/plain.