// The middleware stack — CORS, GZip, and exception handling — mirroring
// Starlette's middleware and FastAPI's exception handlers. A middleware is a
// `@moonasgi.Middleware` ((Handler) -> Handler), composed around the router as
// an onion (see `App::middleware`). Exception handlers map a raised error to a
// response instead of wrapping the handler, matching FastAPI's
// `@app.exception_handler(...)`.
///|
/// An HTTP error a handler can `raise` to short-circuit with a status and body
/// (← FastAPI's `HTTPException`). `detail` is any JSON (a string is the common
/// case); `headers` are added to the response (e.g. a `WWW-Authenticate`
/// challenge). Caught by the app and mapped to a response.
pub(all) suberror HttpException {
HttpException(
status~ : Int,
detail~ : Json,
headers~ : Array[(String, String)]
)
}
///|
/// Build an `HttpException` with a string `detail` — the common case — and
/// optional extra `headers`. `raise http_error(404, "Item not found")` reads
/// like FastAPI's `raise HTTPException(404, "Item not found")`.
pub fn http_error(
status : Int,
detail : String,
headers? : Array[(String, String)] = [],
) -> HttpException {
HttpException(status~, detail=detail.to_json(), headers~)
}
///|
/// The response for an `HttpException`: `{"detail": }` as JSON, at the
/// given status, with the exception's extra headers merged in after the
/// content-type.
fn http_exception_response(
status : Int,
detail : Json,
headers : Array[(String, String)],
) -> @moonasgi.Response {
let body : Map[String, Json] = Map([("detail", detail)])
let hs : Array[(String, String)] = [("content-type", "application/json")]
for h in headers {
hs.push(h)
}
@moonasgi.Response::new(status, hs, @utf8.encode(body.to_json().stringify()))
}
///|
/// The built-in fallback when no registered handler claims a raised error: an
/// `HttpException` becomes its own status and detail; anything else is a
/// `500 {"detail": "Internal Server Error"}`. This is always in effect, so a
/// handler that raises `HttpException` needs no explicit registration.
fn default_exception_response(err : Error) -> @moonasgi.Response {
match err {
HttpException(status~, detail~, headers~) =>
http_exception_response(status, detail, headers)
_ => {
let body : Map[String, Json] = Map([
("detail", "Internal Server Error".to_json()),
])
@moonasgi.Response::new(
500,
[("content-type", "application/json")],
@utf8.encode(body.to_json().stringify()),
)
}
}
}
///|
/// An exception handler: given the request context and the raised error, return
/// `Some(response)` to handle it or `None` to defer to the next handler. The
/// explicit MoonBit form of FastAPI's `@app.exception_handler(ExcType)` — the
/// `None` case stands in for "this handler isn't registered for that type".
pub type ExceptionHandler = (Context, Error) -> @moonasgi.Response?
// -- CORS ---------------------------------------------------------------------
///|
/// CORS policy (← Starlette's `CORSMiddleware`). Origins, methods, and headers
/// are allow-lists; the `*_all` flags open a dimension wholesale. Per the Fetch
/// standard, `allow_credentials` forbids the `*` wildcard in the reflected
/// `Access-Control-Allow-Origin`, so with credentials the request origin is
/// echoed back instead.
pub(all) struct CorsConfig {
allow_origins : Array[String]
allow_all_origins : Bool
allow_methods : Array[String]
allow_headers : Array[String]
allow_all_headers : Bool
allow_credentials : Bool
expose_headers : Array[String]
max_age : Int
}
///|
/// Whether `origin` is permitted: any origin when `allow_all_origins` (or a `*`
/// entry), otherwise an exact match against the allow-list. This is the check
/// the CORS test mutates to prove it's load-bearing.
fn CorsConfig::origin_allowed(self : CorsConfig, origin : String) -> Bool {
if self.allow_all_origins || self.allow_origins.contains("*") {
true
} else {
self.allow_origins.contains(origin)
}
}
///|
/// The value to reflect in `Access-Control-Allow-Origin`: `*` only when all
/// origins are allowed and credentials are off; otherwise the request origin.
fn CorsConfig::allow_origin_value(self : CorsConfig, origin : String) -> String {
if (self.allow_all_origins || self.allow_origins.contains("*")) &&
!self.allow_credentials {
"*"
} else {
origin
}
}
///|
/// Join a list of strings with `, ` — the list form CORS headers use.
fn join_csv(xs : Array[String]) -> String {
let sb = StringBuilder()
for i = 0; i < xs.length(); i = i + 1 {
if i > 0 {
sb.write_string(", ")
}
sb.write_string(xs[i])
}
sb.to_string()
}
///|
/// A copy of `resp` with `extra` headers appended.
fn with_headers(
resp : @moonasgi.Response,
extra : Array[(String, String)],
) -> @moonasgi.Response {
let hs : Array[(String, String)] = []
for h in resp.headers {
hs.push(h)
}
for h in extra {
hs.push(h)
}
@moonasgi.Response::new(resp.status, hs, resp.body)
}
///|
/// A CORS middleware for the given policy. It answers preflight `OPTIONS`
/// requests (those carrying `Access-Control-Request-Method`) directly with a
/// `204` and the negotiated `Access-Control-*` headers, and decorates every
/// other cross-origin response with `Access-Control-Allow-Origin` (plus
/// `Vary: Origin`, exposed headers, and the credentials flag). A request with
/// no `Origin`, or one from a disallowed origin, passes through untouched.
pub fn cors(
allow_origins? : Array[String] = [],
allow_all_origins? : Bool = false,
allow_methods? : Array[String] = [
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS",
],
allow_headers? : Array[String] = [],
allow_all_headers? : Bool = false,
allow_credentials? : Bool = false,
expose_headers? : Array[String] = [],
max_age? : Int = 600,
) -> @moonasgi.Middleware {
let cfg : CorsConfig = {
allow_origins,
allow_all_origins,
allow_methods,
allow_headers,
allow_all_headers,
allow_credentials,
expose_headers,
max_age,
}
downstream => {
request => {
let origin = match request.header("origin") {
None => return downstream(request)
Some(o) => o
}
let is_preflight = request.http_method == "OPTIONS" &&
request.header("access-control-request-method") is Some(_)
if is_preflight {
cfg.preflight_response(request, origin)
} else {
let resp = downstream(request)
if cfg.origin_allowed(origin) {
with_headers(resp, cfg.actual_headers(origin))
} else {
resp
}
}
}
}
}
///|
/// The headers added to an actual (non-preflight) cross-origin response.
fn CorsConfig::actual_headers(
self : CorsConfig,
origin : String,
) -> Array[(String, String)] {
let hs : Array[(String, String)] = [
("access-control-allow-origin", self.allow_origin_value(origin)),
("vary", "Origin"),
]
if self.allow_credentials {
hs.push(("access-control-allow-credentials", "true"))
}
if self.expose_headers.length() > 0 {
hs.push(("access-control-expose-headers", join_csv(self.expose_headers)))
}
hs
}
///|
/// The preflight response: `204` with the negotiated `Access-Control-*` headers
/// when the origin is allowed, or a bare `204` (no allow-origin, so the browser
/// blocks it) when it isn't.
fn CorsConfig::preflight_response(
self : CorsConfig,
request : @moonasgi.Request,
origin : String,
) -> @moonasgi.Response {
if !self.origin_allowed(origin) {
return @moonasgi.Response::new(204, [], b"")
}
let hs : Array[(String, String)] = [
("access-control-allow-origin", self.allow_origin_value(origin)),
("access-control-allow-methods", join_csv(self.allow_methods)),
("access-control-max-age", self.max_age.to_string()),
("vary", "Origin"),
]
let allow_headers = if self.allow_all_headers {
request.header("access-control-request-headers").unwrap_or("*")
} else {
join_csv(self.allow_headers)
}
if allow_headers != "" {
hs.push(("access-control-allow-headers", allow_headers))
}
if self.allow_credentials {
hs.push(("access-control-allow-credentials", "true"))
}
@moonasgi.Response::new(204, hs, b"")
}
// -- GZip ---------------------------------------------------------------------
///|
/// A GZip middleware: responses at least `min_size` bytes are re-encoded as
/// gzip when the client sent `Accept-Encoding: gzip` and the response isn't
/// already content-encoded. Sets `Content-Encoding: gzip`, updates
/// `Content-Length`, and adds `Vary: Accept-Encoding`.
///
/// The gzip stream is a complete RFC 1952 container — correct header, CRC-32, and
/// ISIZE — around a real RFC 1951 DEFLATE payload: LZ77 back-references coded with
/// the fixed Huffman table (`deflate.mbt`), so the body actually shrinks. Dynamic
/// Huffman would tighten the ratio further and is the documented next step.
pub fn gzip(min_size? : Int = 500) -> @moonasgi.Middleware {
downstream => {
request => {
let resp = downstream(request)
if resp.body.length() < min_size {
return resp
}
if !accepts_gzip(request) {
return resp
}
if resp.header("content-encoding") is Some(_) {
return resp
}
let encoded = gzip_encode(resp.body)
let hs : Array[(String, String)] = []
for h in resp.headers {
if h.0 != "content-length" {
hs.push(h)
}
}
hs.push(("content-encoding", "gzip"))
hs.push(("content-length", encoded.length().to_string()))
hs.push(("vary", "Accept-Encoding"))
@moonasgi.Response::new(resp.status, hs, encoded)
}
}
}
///|
/// Whether the request's `Accept-Encoding` offers gzip.
fn accepts_gzip(request : @moonasgi.Request) -> Bool {
match request.header("accept-encoding") {
None => false
Some(v) => v.to_lower().contains("gzip")
}
}
///|
/// CRC-32 (IEEE 802.3 polynomial `0xEDB88320`, reflected) over `data`, the
/// checksum the gzip trailer carries. Table-free bitwise form — core ships no
/// zlib. Checked against the `zlib.crc32` vector in the tests.
fn crc32(data : Bytes) -> UInt {
let mut crc : UInt = 0xFFFFFFFF
for i = 0; i < data.length(); i = i + 1 {
crc = crc ^ data[i].to_int().reinterpret_as_uint()
for _j = 0; _j < 8; _j = _j + 1 {
let mask = 0U - (crc & 1U)
crc = (crc >> 1) ^ (0xEDB88320U & mask)
}
}
crc ^ 0xFFFFFFFF
}
///|
/// Append the low 32 bits of `n` to `buf`, little-endian — gzip's CRC-32 and
/// ISIZE fields are both stored this way.
fn write_u32_le(buf : Buffer, n : UInt) -> Unit {
buf.write_byte((n & 0xFF).to_byte())
buf.write_byte(((n >> 8) & 0xFF).to_byte())
buf.write_byte(((n >> 16) & 0xFF).to_byte())
buf.write_byte(((n >> 24) & 0xFF).to_byte())
}
///|
/// Encode `data` as a gzip stream (RFC 1952): the 10-byte header, the real
/// DEFLATE payload (`deflate_encode` — LZ77 + fixed Huffman, RFC 1951), then the
/// CRC-32 and ISIZE trailer, both little-endian over the original bytes.
fn gzip_encode(data : Bytes) -> Bytes {
let buf = Buffer()
// gzip header: magic, CM=deflate(8), no flags, mtime=0, XFL=0, OS=unknown.
buf.write_byte(b'\x1f')
buf.write_byte(b'\x8b')
buf.write_byte(b'\x08')
buf.write_byte(b'\x00')
buf.write_byte(b'\x00')
buf.write_byte(b'\x00')
buf.write_byte(b'\x00')
buf.write_byte(b'\x00')
buf.write_byte(b'\x00')
buf.write_byte(b'\xff')
buf.write_bytes(deflate_encode(data))
write_u32_le(buf, crc32(data))
write_u32_le(buf, data.length().reinterpret_as_uint())
buf.to_bytes()
}