// 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`), and the raw `content` bytes exactly as received.
pub(all) struct UploadFile {
name : String
filename : String
content_type : String
content : Bytes
} derive(Eq)
///|
/// 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
}
// -- 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.
fn parse_urlencoded(body : Bytes) -> 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 {
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
}
}
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 header block (decoded as text) and the byte
/// range of its content within the body.
priv struct Part {
headers : 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.
fn split_parts(body : Bytes, boundary : String) -> 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
}
let 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
}
parts
}
///|
/// Read one header line's value out of a part's header block, `None` if the part
/// has no such header. Header names are matched case-insensitively.
fn part_header(headers : String, name : String) -> String? {
let lower = headers.to_lower()
let key = name.to_lower() + ":"
let idx = str_index_of(lower, key, 0)
if idx < 0 {
return None
}
let mut i = idx + key.length()
let n = headers.length()
let sb = StringBuilder()
while i < n && headers[i].to_int() != 0x0D && headers[i].to_int() != 0x0A {
sb.write_char(headers[i].unsafe_to_char())
i = i + 1
}
Some(trim_spaces(sb.to_string()))
}
///|
/// 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`.
fn parse_multipart(body : Bytes, boundary : String) -> FormData {
let fields : Array[FormField] = []
let files : Array[UploadFile] = []
for part in split_parts(body, boundary) {
let disposition = part_header(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 = part_header(part.headers, "content-type").unwrap_or(
"",
)
files.push({ name, filename, content_type, content, })
}
None => fields.push({ name, value: @utf8.decode_lossy(content[:]), })
}
}
{ 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.
pub fn Context::form(self : Context) -> 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)
None => { fields: [], files: [], }
}
} else if str_index_of(ct_lower, "application/x-www-form-urlencoded", 0) >= 0 {
{ fields: parse_urlencoded(self.request.body), files: [], }
} else {
{ fields: [], files: [], }
}
}