// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Cross-origin resource sharing, as Starlette's `CORSMiddleware` does it (Fetch standard ยง3.2).
///
/// `origins`, `methods` and `headers` are allow-lists, and `"*"` in one allows anything. A
/// preflight is answered here: 200 with the negotiated headers, or 400 naming what was refused.
/// Other cross-origin responses get `access-control-allow-origin` when the origin is allowed.
/// With `credentials` the origin is echoed rather than `*`, as the standard requires.
pub(all) struct Cors {
origins : Array[String]
methods : Array[String]
headers : Array[String]
credentials : Bool
expose : Array[String]
max_age : Int32
} derive(Eq, Debug)
///|
pub extend Cors with Eq::{equal, not_equal}
///|
pub extend Cors with Debug::{to_repr}
///|
/// Starlette's defaults: no origin, `GET` only, the safelisted headers only, no credentials,
/// preflights cached for ten minutes.
pub let cors : Cors = {
origins: [],
methods: ["GET"],
headers: [],
credentials: false,
expose: [],
max_age: 600,
}
///|
pub fn Cors::new(
origins~ : Array[String] = cors.origins,
methods~ : Array[String] = cors.methods,
headers~ : Array[String] = cors.headers,
credentials~ : Bool = cors.credentials,
expose~ : Array[String] = cors.expose,
max_age~ : Int32 = cors.max_age,
) -> Cors {
{ origins, methods, headers, credentials, expose, max_age, }
}
///|
/// The request headers a preflight need not ask for (Fetch standard, CORS-safelisted).
let safelisted : Array[String] = [
"accept", "accept-language", "content-language", "content-type",
]
///|
let all_methods : Array[String] = [
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT",
]
///|
/// Dictionary order. `String`'s own order puts shorter strings first, and the header list is
/// meant to read as Starlette writes it, sorted by name.
fn lexical(a : String, b : String) -> Int32 {
let n = @cmp.minimum(a.length(), b.length())
for i in 0.. Bool {
self.origins.contains("*")
}
///|
fn Cors::allows_origin(self : Cors, origin : String) -> Bool {
self.any_origin() || self.origins.contains(origin)
}
///|
fn Cors::allowed_methods(self : Cors) -> Array[String] {
if self.methods.contains("*") {
all_methods
} else {
self.methods
}
}
///|
fn Cors::preflight(
self : Cors,
origin : String,
requested : String,
asked : String?,
) -> (Int32, Array[(String, String)], String) {
let explicit = !self.any_origin() || self.credentials
let allowed_headers = {
let names = safelisted.copy()
for h in self.headers {
let lower = h.to_lower()
if !names.contains(lower) {
names.push(lower)
}
}
names.sort_by(lexical)
names
}
let any_header = self.headers.contains("*")
let out : Array[(String, String)] = []
if explicit {
out.push(("vary", "Origin"))
} else {
out.push(("access-control-allow-origin", "*"))
}
out.push(("access-control-allow-methods", self.allowed_methods().join(", ")))
out.push(("access-control-max-age", self.max_age.to_string()))
if !any_header {
out.push(("access-control-allow-headers", allowed_headers.join(", ")))
}
if self.credentials {
out.push(("access-control-allow-credentials", "true"))
}
let refused : Array[String] = []
if self.allows_origin(origin) {
if explicit {
out.push(("access-control-allow-origin", origin))
}
} else {
refused.push("origin")
}
if !self.allowed_methods().contains(requested) {
refused.push("method")
}
match asked {
Some(list) if any_header => {
let rest = out.filter(h => h.0 != "access-control-allow-headers")
out.clear()
out.push_iter([..rest, ("access-control-allow-headers", list)].iter())
}
Some(list) =>
for name in list.split(",") {
if !allowed_headers.contains(name.trim(chars=" ").to_owned().to_lower()) {
refused.push("headers")
break
}
}
None => ()
}
if refused.length() > 0 {
(400, out, "Disallowed CORS " + refused.join(", "))
} else {
(200, out, "OK")
}
}
///|
/// The headers every cross-origin answer carries, and the allow-origin when it is due.
fn Cors::simple(
self : Cors,
headers : Array[(String, String)],
origin : String,
cookie : Bool,
) -> Array[(String, String)] {
let mut out = headers
if self.any_origin() {
out = put(out, "access-control-allow-origin", "*")
}
if self.credentials {
out = put(out, "access-control-allow-credentials", "true")
}
if self.expose.length() > 0 {
out = put(out, "access-control-expose-headers", self.expose.join(", "))
}
// A request with a cookie must not be answered with `*`; one from a listed origin is
// answered with that origin.
if (self.any_origin() && cookie) ||
(!self.any_origin() && self.allows_origin(origin)) {
out = vary(put(out, "access-control-allow-origin", origin), "Origin")
}
out
}
///|
pub impl Layer for Cors with fn wrap(self, app) {
(scope, receive, send) => {
guard scope is Http(h) && header(h.headers, "origin") is Some(origin) else {
app(scope, receive, send)
return
}
if h.http_method == "OPTIONS" &&
header(h.headers, "access-control-request-method") is Some(requested) {
let (status, headers, body) = self.preflight(
origin,
requested,
header(h.headers, "access-control-request-headers"),
)
respond(send, status, headers, body)
return
}
let cookie = header(h.headers, "cookie") is Some(_)
app(scope, receive, event => {
match event {
HttpResponseStart(status~, headers~, trailers~) =>
send(
HttpResponseStart(
status~,
headers=self.simple(headers, origin, cookie),
trailers~,
),
)
other => send(other)
}
})
}
}
///|
pub extend Cors with Layer::{wrap}