// The request-body form extractors — FastAPI's `Form(...)` and `File(...)`
// parameters. Two content types carry a submitted form: an urlencoded body
// (`application/x-www-form-urlencoded`) of percent-encoded `key=value` pairs,
// and a multipart body (`multipart/form-data`) whose parts each carry their own
// headers and can hold raw file bytes. Both parse to one `FormData`, with plain
// fields and uploaded files kept apart. The parsing is byte-level, so file
// contents survive untouched (a JPEG isn't valid UTF-8).
///|
/// A plain form field: a `name` and its decoded text `value`.
pub(all) struct FormField {
name : String
value : String
} derive(Eq)
///|
/// An uploaded file from a multipart part: the form-field `name` it came under,
/// the client's `filename`, its declared `content_type` (empty when the part
/// carried no `Content-Type`), the raw `content` bytes exactly as received, and
/// the part's own `headers` with their names lowercased, in body order.
///
/// `content_type` is kept as its own field because nearly every caller wants it
/// and nothing else; `headers` is there for the rest — a
/// `Content-Transfer-Encoding` a handler must honour, a checksum a client
/// attached — which otherwise had nowhere to be read from.
pub(all) struct UploadFile {
name : String
filename : String
content_type : String
content : Bytes
headers : Array[(String, String)]
} derive(Eq)
///|
/// The uploaded size in bytes (← FastAPI's `UploadFile.size`). Derived rather
/// than stored: a field could be set to disagree with `content`, and a size that
/// lies about the bytes beside it is worse than no size at all.
pub fn UploadFile::size(self : UploadFile) -> Int {
self.content.length()
}
///|
/// Look up one of the part's own headers by name, `None` if it carried no such
/// header. Names are matched lowercased, the same convention as
/// `@moonasgi.Request::header`.
pub fn UploadFile::header(self : UploadFile, name : String) -> String? {
header_of(self.headers, name)
}
///|
/// A parsed form: its plain `fields` and its uploaded `files`, in body order. A
/// multipart part is a file when its `Content-Disposition` carries a `filename`;
/// otherwise it's a field. An urlencoded body only ever yields fields.
pub(all) struct FormData {
fields : Array[FormField]
files : Array[UploadFile]
} derive(Eq)
///|
/// The value of the first field named `name`, `None` if absent — the common
/// `Form(...)` lookup.
pub fn FormData::field(self : FormData, name : String) -> String? {
for f in self.fields {
if f.name == name {
return Some(f.value)
}
}
None
}
///|
/// Every value submitted under `name`, in order — an HTML form can repeat a
/// field (checkbox groups, multi-selects), and FastAPI surfaces those as a list.
pub fn FormData::field_all(self : FormData, name : String) -> Array[String] {
let out : Array[String] = []
for f in self.fields {
if f.name == name {
out.push(f.value)
}
}
out
}
///|
/// The first uploaded file under `name`, `None` if absent — the `File(...)`
/// lookup.
pub fn FormData::file(self : FormData, name : String) -> UploadFile? {
for f in self.files {
if f.name == name {
return Some(f)
}
}
None
}
///|
/// What a submitted form may cost before it is refused: how many parts it may
/// carry, and how many bytes any one part may hold.
///
/// A request body is attacker-controlled and, on this SEAM, fully buffered before
/// a route ever sees it. Unbounded, a body that is nothing but boundaries becomes
/// as many parts as it has bytes — every one of them an allocation the app made
/// on the sender's say-so, on top of the body it already holds.
pub(all) struct FormLimits {
max_parts : Int
max_part_size : Int
} derive(Eq)
///|
/// Limits with Starlette's defaults — 1000 parts of at most 1 MiB — or either
/// bound overridden.
pub fn FormLimits::new(
max_parts? : Int = 1000,
max_part_size? : Int = 1024 * 1024,
) -> FormLimits {
{ max_parts, max_part_size, }
}
///|
/// Look up a header by name in a lowercased-name pair list.
fn header_of(headers : Array[(String, String)], name : String) -> String? {
let key = name.to_lower()
for h in headers {
if h.0 == key {
return Some(h.1)
}
}
None
}
// -- byte helpers -------------------------------------------------------------
///|
/// Whether `hay` has `needle` starting at `at`.
fn bytes_has_at(hay : Bytes, at : Int, needle : Bytes) -> Bool {
if at < 0 || at + needle.length() > hay.length() {
return false
}
for i = 0; i < needle.length(); i = i + 1 {
if hay[at + i] != needle[i] {
return false
}
}
true
}
///|
/// The first index at or after `from` where `needle` occurs in `hay`, `-1` if it
/// doesn't. A plain scan — the bodies here are small enough that a smarter search
/// buys nothing.
fn bytes_index_of(hay : Bytes, needle : Bytes, from : Int) -> Int {
if needle.length() == 0 {
return from
}
let last = hay.length() - needle.length()
for i = from; i <= last; i = i + 1 {
if bytes_has_at(hay, i, needle) {
return i
}
}
-1
}
///|
/// The hex value of an ASCII byte (`0`-`9`, `a`-`f`, `A`-`F`), `None` otherwise —
/// used to decode a `%XX` escape.
fn hex_val(b : Byte) -> Int? {
let c = b.to_int()
if c >= 0x30 && c <= 0x39 {
Some(c - 0x30)
} else if c >= 0x61 && c <= 0x66 {
Some(c - 0x61 + 10)
} else if c >= 0x41 && c <= 0x46 {
Some(c - 0x41 + 10)
} else {
None
}
}
// -- urlencoded ---------------------------------------------------------------
///|
/// Percent-decode the byte range `raw[start:end]` into bytes: `%XX` becomes the
/// byte it names, `+` becomes a space, everything else passes through. A stray
/// `%` with no two hex digits after it is kept literally, the way permissive
/// decoders treat malformed input.
fn percent_decode(raw : Bytes, start : Int, end : Int) -> Bytes {
let out = Buffer()
let mut i = start
while i < end {
let c = raw[i]
if c == b'%' && i + 2 < end {
match (hex_val(raw[i + 1]), hex_val(raw[i + 2])) {
(Some(hi), Some(lo)) => {
out.write_byte((hi * 16 + lo).to_byte())
i = i + 3
}
_ => {
out.write_byte(c)
i = i + 1
}
}
} else if c == b'+' {
out.write_byte(b' ')
i = i + 1
} else {
out.write_byte(c)
i = i + 1
}
}
out.to_bytes()
}
///|
/// The decoded text of the byte range `raw[start:end]` after percent/plus
/// decoding.
fn decode_component(raw : Bytes, start : Int, end : Int) -> String {
@utf8.decode_lossy(percent_decode(raw, start, end)[:])
}
///|
/// Parse an `application/x-www-form-urlencoded` body into fields: split on `&`
/// into pairs, split each on its first `=`, percent/plus-decode both halves. A
/// pair with no `=` is a bare key with an empty value. `None` when the body
/// breaks `limits` — a pair counts as a part, since it costs one the same way.
fn parse_urlencoded(body : Bytes, limits : FormLimits) -> Array[FormField]? {
let out : Array[FormField] = []
let n = body.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || body[i] == b'&' {
if i > start {
if out.length() == limits.max_parts || i - start > limits.max_part_size {
return None
}
let mut eq = -1
for j = start; j < i; j = j + 1 {
if body[j] == b'=' {
eq = j
break
}
}
let (name, value) = if eq < 0 {
(decode_component(body, start, i), "")
} else {
(decode_component(body, start, eq), decode_component(body, eq + 1, i))
}
out.push({ name, value, })
}
start = i + 1
}
}
Some(out)
}
// -- multipart/form-data ------------------------------------------------------
///|
/// Pull a quoted or bare parameter value out of a header-parameter string, e.g.
/// `name` from `form-data; name="file"; filename="a.txt"`. Matches `key=` then
/// takes the following `"..."` (or an unquoted run up to `;`).
fn header_param(header : String, key : String) -> String? {
let needle = key + "="
let idx = str_index_of(header, needle, 0)
if idx < 0 {
return None
}
let mut i = idx + needle.length()
let n = header.length()
if i < n && header[i].to_int() == 0x22 {
// quoted
i = i + 1
let sb = StringBuilder()
while i < n && header[i].to_int() != 0x22 {
sb.write_char(header[i].unsafe_to_char())
i = i + 1
}
Some(sb.to_string())
} else {
let sb = StringBuilder()
while i < n && header[i].to_int() != 0x3B {
sb.write_char(header[i].unsafe_to_char())
i = i + 1
}
Some(trim_spaces(sb.to_string()))
}
}
///|
/// The first index at or after `from` where `needle` occurs in `s`, `-1` if it
/// doesn't — the `String` analogue of `bytes_index_of`, for header text.
fn str_index_of(s : String, needle : String, from : Int) -> Int {
let m = needle.length()
if m == 0 {
return from
}
let last = s.length() - m
for i = from; i <= last; i = i + 1 {
let mut ok = true
for j = 0; j < m; j = j + 1 {
if s[i + j] != needle[j] {
ok = false
break
}
}
if ok {
return i
}
}
-1
}
///|
/// One parsed multipart part: its own headers and the byte range of its content
/// within the body.
priv struct Part {
headers : Array[(String, String)]
content_start : Int
content_end : Int
}
///|
/// Split a `multipart/form-data` body on its `boundary` into parts. Each part is
/// introduced by `--boundary` followed by CRLF; the closing delimiter is
/// `--boundary--`. Between the delimiter's trailing CRLF and the next
/// `--boundary` lies the part — its header block, a blank `CRLF CRLF`, then its
/// content. The two bytes before the next delimiter are that part's own trailing
/// CRLF and don't belong to the content.
///
/// `None` once the body would yield more than `max_parts`, which is checked
/// before each part is built rather than after the split, so a body of nothing
/// but boundaries costs the bound and not its own length.
fn split_parts(
body : Bytes,
boundary : String,
max_parts : Int,
) -> Array[Part]? {
let parts : Array[Part] = []
let dash = @utf8.encode("--" + boundary)
let crlf = b"\r\n"
let mut pos = bytes_index_of(body, dash, 0)
while pos >= 0 {
let after = pos + dash.length()
// Closing delimiter `--boundary--`: nothing more to read.
if bytes_has_at(body, after, b"--") {
break
}
// A part delimiter is `--boundary CRLF`; skip the CRLF to the header block.
let head_start = if bytes_has_at(body, after, crlf) {
after + 2
} else {
after
}
let sep = bytes_index_of(body, b"\r\n\r\n", head_start)
let next = bytes_index_of(body, dash, head_start)
if sep < 0 || next < 0 || sep > next {
// Malformed part — stop rather than invent structure.
break
}
if parts.length() == max_parts {
return None
}
let headers = part_headers(
@utf8.decode_lossy(body[head_start:sep].to_owned()[:]),
)
let content_start = sep + 4
let content_end = next - 2 // strip the CRLF that precedes the next delimiter
parts.push({
headers,
content_start,
content_end: if content_end < content_start {
content_start
} else {
content_end
},
})
pos = next
}
Some(parts)
}
///|
/// Split a part's header block into `(lowercased name, value)` pairs, one per
/// line. A line without a colon is not a header field, and is dropped rather
/// than guessed at.
fn part_headers(block : String) -> Array[(String, String)] {
let out : Array[(String, String)] = []
let n = block.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || block[i].to_int() == 0x0A {
let end = if i > start && block[i - 1].to_int() == 0x0D {
i - 1
} else {
i
}
let line = block[start:end].to_owned()
let mut colon = -1
for j = 0; j < line.length(); j = j + 1 {
if line[j] == ':' {
colon = j
break
}
}
if colon > 0 {
out.push(
(
trim_spaces(line[0:colon].to_owned()).to_lower(),
trim_spaces(line[colon + 1:].to_owned()),
),
)
}
start = i + 1
}
}
out
}
///|
/// Parse a `multipart/form-data` body given its `boundary` into a `FormData`,
/// sorting each part into a field or a file by whether its `Content-Disposition`
/// carries a `filename`. `None` when the body breaks `limits`.
fn parse_multipart(
body : Bytes,
boundary : String,
limits : FormLimits,
) -> FormData? {
let fields : Array[FormField] = []
let files : Array[UploadFile] = []
let parts = match split_parts(body, boundary, limits.max_parts) {
Some(ps) => ps
None => return None
}
for part in parts {
if part.content_end - part.content_start > limits.max_part_size {
return None
}
let disposition = header_of(part.headers, "content-disposition").unwrap_or(
"",
)
let name = header_param(disposition, "name").unwrap_or("")
let content = body[part.content_start:part.content_end].to_owned()
match header_param(disposition, "filename") {
Some(filename) => {
let content_type = header_of(part.headers, "content-type").unwrap_or("")
files.push({
name,
filename,
content_type,
content,
headers: part.headers,
})
}
None => fields.push({ name, value: @utf8.decode_lossy(content[:]), })
}
}
Some({ fields, files, })
}
///|
/// The `boundary` parameter of a `multipart/form-data` content-type header,
/// `None` if it's absent.
fn boundary_of(content_type : String) -> String? {
header_param(content_type, "boundary")
}
// -- Context extractors -------------------------------------------------------
///|
/// The parsed request form — FastAPI's `Form(...)` / `File(...)` parameters.
/// Dispatches on the `Content-Type`: a `multipart/form-data` body is split on
/// its boundary into fields and files, an `application/x-www-form-urlencoded`
/// body is decoded into fields. Any other (or absent) content type yields an
/// empty form rather than raising, so the extractor stays total.
///
/// `None` when the body breaks `limits` — more parts than `max_parts`, or a part
/// longer than `max_part_size`. The form is refused whole rather than truncated:
/// a handler given the first thousand parts of a larger form would answer a
/// request nobody sent. An empty `Some` is the other answer, and means the
/// request carried no form at all.
pub fn Context::form(
self : Context,
limits? : FormLimits = FormLimits::new(),
) -> FormData? {
let ct = self.request.header("content-type").unwrap_or("")
let ct_lower = ct.to_lower()
if str_index_of(ct_lower, "multipart/form-data", 0) >= 0 {
match boundary_of(ct) {
Some(b) => parse_multipart(self.request.body, b, limits)
None => Some({ fields: [], files: [], })
}
} else if str_index_of(ct_lower, "application/x-www-form-urlencoded", 0) >= 0 {
match parse_urlencoded(self.request.body, limits) {
Some(fields) => Some({ fields, files: [], })
None => None
}
} else {
Some({ fields: [], files: [], })
}
}