// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Something that wraps one ASGI application in another: CORS, compression, a host check, or the
/// caller's own. Every middleware here is a record that implements it, so a caller's middleware
/// stacks with them on equal terms.
pub(open) trait Layer {
fn wrap(Self, @spec.AsgiApp) -> @spec.AsgiApp
}
///|
/// A middleware written as a plain function from application to application.
pub(all) struct Wrap((@spec.AsgiApp) -> @spec.AsgiApp)
///|
pub impl Layer for Wrap with fn wrap(self, app) {
let Wrap(f) = self
f(app)
}
///|
/// `app` inside `layers`, the first outermost: it sees the request first and the response last,
/// the order Starlette's `middleware=[...]` gives.
pub fn stack(layers : Array[&Layer], app : @spec.AsgiApp) -> @spec.AsgiApp {
let mut out = app
for i = layers.length() - 1; i >= 0; i = i - 1 {
out = layers[i].wrap(out)
}
out
}
///|
/// What the host and redirect checks read from an HTTP or WebSocket scope.
priv struct View {
websocket : Bool
scheme : String
headers : Array[(String, String)]
target : String
server : (String, Int32?)?
}
///|
fn view(scope : @spec.Scope) -> View? {
match scope {
Http(h) =>
Some({
websocket: false,
scheme: h.scheme,
headers: h.headers,
target: target(h.root_path, h.path, h.query_string),
server: h.server,
})
WebSocket(w) =>
Some({
websocket: true,
scheme: w.scheme,
headers: w.headers,
target: target(w.root_path, w.path, w.query_string),
server: w.server,
})
Lifespan(_) => None
}
}
///|
fn target(root : String, path : String, query : Bytes) -> String {
let q = @utf8.decode_lossy(query[:])
root + path + (if q == "" { "" } else { "?" + q })
}
///|
/// The value of the request header `name`, matched without regard to case.
fn header(headers : Array[(String, String)], name : String) -> String? {
for h in headers {
if h.0.to_lower() == name {
return Some(h.1)
}
}
None
}
///|
/// The headers with `name` set to `value`, replacing any earlier value.
fn put(
headers : Array[(String, String)],
name : String,
value : String,
) -> Array[(String, String)] {
[..headers.filter(h => h.0.to_lower() != name), (name, value)]
}
///|
/// The headers with `token` added to `vary`, once.
fn vary(
headers : Array[(String, String)],
token : String,
) -> Array[(String, String)] {
match header(headers, "vary") {
None => [..headers, ("vary", token)]
Some(v) =>
if v.split(",").any(t => t.trim(chars=" ").to_lower() == token.to_lower()) {
headers
} else {
put(headers, "vary", "\{v}, \{token}")
}
}
}
///|
/// Answer without the application: a status, headers and a plain-text body. On a WebSocket the
/// answer denies the handshake, by the `websocket.http.response` extension, as Starlette's
/// responses do on a WebSocket scope.
async fn respond(
send : @spec.Send,
status : Int32,
headers : Array[(String, String)],
body : String,
websocket~ : Bool = false,
) -> Unit {
let bytes = @utf8.encode(body)
let all = [
..headers,
("content-type", "text/plain; charset=utf-8"),
("content-length", bytes.length().to_string()),
]
if websocket {
send(WebSocketHttpResponseStart(status~, headers=all, trailers=false))
send(WebSocketHttpResponseBody(body=bytes, more_body=false))
} else {
send(HttpResponseStart(status~, headers=all, trailers=false))
send(HttpResponseBody(body=bytes, more_body=false))
}
}
///|
/// A 307 to `location`: the method and body are kept, as Starlette's `RedirectResponse` does.
async fn redirect(
send : @spec.Send,
location : String,
websocket : Bool,
) -> Unit {
respond(send, 307, [("location", location)], "", websocket~)
}
///|
pub extend Wrap with Layer::{wrap}