// Responses that are an envelope rather than a body: a redirect, a download,
// a data URL, and a download that weighs the request first.
///|
/// A redirect to `url` (← FastAPI's `RedirectResponse`).
///
/// `307` keeps the method and body, which `302` historically does not. Use `303`
/// after a write, `301` / `308` for a permanent move.
///
/// The URL is escaped as a whole URI reference, so an encoded one is not encoded
/// twice and a `\r\n` cannot open a header of its own.
pub fn redirect(url : String, status? : Int = 307) -> @moonasgi.Response {
@moonasgi.Response::new(
status,
[("location", @url.escape_text(url[:], kind=Whole))],
b"",
)
}
///|
/// A download (← FastAPI's `FileResponse`): a media type from `filename`'s
/// extension unless `media_type` names one, `Content-Length`, and a
/// `Content-Disposition`. No `filename`, no disposition.
///
/// Bytes rather than a path: the same app runs on four backends and only the
/// server can read a file on any of them.
///
/// Always the whole thing. [`Context::serve`] weighs the request first.
pub fn file_response(
content : Bytes,
filename? : String = "",
media_type? : String = "",
status? : Int = 200,
inline? : Bool = false,
) -> @moonasgi.Response {
@moonasgi.Response::new(
status,
envelope(content.length(), filename, media_type, inline),
content,
)
}
///|
/// Serve what a `data:` URL carries (RFC 2397), under the media type it names —
/// what `canvas.toDataURL()` and `FileReader.readAsDataURL()` hand back.
///
/// `None` when it is not a data URL, so a handler tells "no picture" from "not a
/// picture" without touching base64.
pub fn data_response(
url : String,
filename? : String = "",
status? : Int = 200,
inline? : Bool = true,
) -> @moonasgi.Response? {
try {
let url = @dataurl.Url::decode(url[:])
Some(
file_response(url.data, filename~, media_type=url.kind, status~, inline~),
)
} catch {
_ => None
}
}
///|
/// The content as a `data:` URL, for embedding it instead of costing a second
/// request.
pub fn data_url(content : Bytes, media_type? : String = "") -> String {
let kind = if media_type != "" { media_type } else { @dataurl.kind }
@dataurl.Url::new(content, kind~).encode()
}
///|
/// [`file_response`] with the request weighed first (RFC 9110 §13, §14):
///
/// * `304` — the client's copy is current.
/// * `412` — a precondition it stated does not hold.
/// * `206` — it asked for part, one `Content-Range` or a `multipart/byteranges`.
/// * `416` — it asked for what is not there.
///
/// Everything turns on `etag`. Left out, one is hashed from the content, which
/// costs a pass over the body; pass `@conditional.stamp(length~, modified~)` for
/// one stat's worth instead, or any tag of your own.
///
/// `ranges=false` withdraws the offer in `Accept-Ranges`.
pub fn Context::serve(
self : Context,
content : Bytes,
filename? : String = "",
media_type? : String = "",
inline? : Bool = false,
etag? : @conditional.Tag,
modified? : @moondate.Moment,
ranges? : Bool = true,
) -> @moonasgi.Response {
let tag = match etag {
Some(tag) => tag
None => @conditional.hash(content[:], digest=@sha2.Hasher::new())
}
let verb = self.request.http_method
let ask = @conditional.Ask::read(self.request.headers[:])
let validators = [("etag", tag.encode())]
match modified {
Some(modified) =>
validators.push(("last-modified", modified.http_text())) catch {
_ => ()
}
None => ()
}
match ask.evaluate(verb~, etag=tag, modified?) {
// §15.4.5: no body, and the validators the 200 would have carried.
Fresh => @moonasgi.Response::new(304, validators, b"")
Failed => @moonasgi.Response::new(412, validators, b"")
Go => {
let headers = envelope(content.length(), filename, media_type, inline)
for one in validators {
headers.push(one)
}
headers.push(("accept-ranges", if ranges { "bytes" } else { "none" }))
if !ranges || verb != "GET" {
return @moonasgi.Response::new(200, headers, body(verb, content))
}
// §13.1.5: a Range that If-Range ruled out asks for the whole thing.
guard asked(self.request.headers, "range") is Some(field) &&
ask.unchanged(etag=tag, modified?) else {
return @moonasgi.Response::new(200, headers, body(verb, content))
}
guard @range.decode(field[:]) is Some(specs) else {
return @moonasgi.Response::new(200, headers, body(verb, content))
}
let whole = content.length().to_int64()
guard @range.measure(specs[:], length=whole) is Some(parts) else {
let refused = [("content-range", @range.unsatisfied(whole).encode())]
return @moonasgi.Response::new(416, refused, b"")
}
partial(verb, content, parts, headers, whole)
}
}
}
///|
/// The 206, in whichever shape was asked for (§14.4, §14.6).
fn partial(
verb : String,
content : Bytes,
parts : Array[@range.Part],
headers : Array[(String, String)],
whole : Int64,
) -> @moonasgi.Response {
let kind = field_of(headers, "content-type")
if parts.length() == 1 {
let part = parts[0]
let piece = cut(content, part)
let out = without(headers, ["content-length", "accept-ranges"])
out.push(("content-length", piece.length().to_string()))
out.push(("accept-ranges", "bytes"))
out.push(
("content-range", @range.Content::new(part, complete=whole).encode()),
)
return @moonasgi.Response::new(206, out, body(verb, piece))
}
// From the tag rather than at random: this package holds no randomness, and a
// boundary that changed between identical requests could not be tested.
let boundary = "moonapi-" + token(field_of(headers, "etag"))
let pieces = parts.map(fn(part) { (part, cut(content, part)) })
let piece = @range.parts(pieces[:], boundary~, kind~, complete=whole)
let out = without(headers, ["content-type", "content-length"])
out.push(("content-type", @range.multipart(boundary[:])))
out.push(("content-length", piece.length().to_string()))
@moonasgi.Response::new(206, out, body(verb, piece))
}
///|
/// What the body is, how long, and what to save it as.
fn envelope(
length : Int,
filename : String,
media_type : String,
inline : Bool,
) -> Array[(String, String)] {
let kind = if media_type != "" {
media_type
} else {
@media.type_of(filename[:])
}
let headers : Array[(String, String)] = [
("content-type", kind),
("content-length", length.to_string()),
]
if filename != "" {
headers.push(
("content-disposition", @media.disposition(filename[:], inline~)),
)
}
headers
}
///|
/// A `HEAD` gets the headers and nothing after them (§9.3.2).
fn body(verb : String, content : Bytes) -> Bytes {
if verb == "HEAD" {
b""
} else {
content
}
}
///|
fn cut(content : Bytes, part : @range.Part) -> Bytes {
let out : Array[Byte] = []
for i = part.at.to_int(); i <= part.last.to_int(); i = i + 1 {
out.push(content[i])
}
Bytes::from_array(out)
}
///|
/// The part of a tag a boundary may contain, within the seventy octets RFC 2046
/// §5.1.1 allows, less the prefix's eight.
fn token(text : String) -> String {
let out = StringBuilder()
for i = 0; i < text.length(); i = i + 1 {
let b = text[i].to_int()
let ok = (b >= 0x41 && b <= 0x5A) ||
(b >= 0x61 && b <= 0x7A) ||
(b >= 0x30 && b <= 0x39) ||
b == 0x2D
if ok {
out.write_char(b.unsafe_to_char())
}
}
let text = out.to_string()
if text == "" {
return "part"
}
if text.length() > 62 {
text[text.length() - 62:].to_owned()
} else {
text
}
}
///|
/// A request header by name, ignoring case — the rule `Ask::read` uses, so
/// `Range` and `If-Range` are not read by two different ones.
fn asked(headers : Array[(String, String)], name : String) -> String? {
for pair in headers {
if pair.0.to_lower() == name {
return Some(pair.1)
}
}
None
}
///|
fn field_of(headers : Array[(String, String)], name : String) -> String {
for pair in headers {
if pair.0 == name {
return pair.1
}
}
""
}
///|
fn without(
headers : Array[(String, String)],
names : Array[String],
) -> Array[(String, String)] {
let out : Array[(String, String)] = []
for pair in headers {
let mut drop = false
for name in names {
if pair.0 == name {
drop = true
}
}
if !drop {
out.push(pair)
}
}
out
}