// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A named text value from a form.
pub(all) struct Field {
name : String
value : String
} derive(Eq, Debug)
///|
pub extend Field with Eq::{equal, not_equal}
///|
pub extend Field with Debug::{to_repr}
///|
/// A file from a `multipart/form-data` body.
///
/// The content is held whole. Streaming a part to disk instead of into memory
/// is what `limits` exists to make unnecessary until it is built.
pub(all) struct Upload {
name : String
filename : String
content_type : String
content : Bytes
headers : Array[(String, String)]
} derive(Eq, Debug)
///|
pub extend Upload with Eq::{equal, not_equal}
///|
pub extend Upload with Debug::{to_repr}
///|
/// The size of the uploaded content in bytes.
pub fn Upload::size(self : Upload) -> Int32 {
self.content.length()
}
///|
/// One of the part's own headers, by lowercased name.
pub fn Upload::header(self : Upload, name : StringView) -> String? {
header_of(self.headers, name)
}
///|
/// A parsed form: its text fields and its files.
struct Form {
fields : Array[Field]
files : Array[Upload]
}
///|
/// A form holding these fields and files, for a decoder of another encoding
/// (`application/x-www-form-urlencoded` lives in moonurl) to hand back the same
/// type this package reads multipart into.
pub fn Form::new(fields? : Array[Field], files? : Array[Upload]) -> Form {
{
fields: fields.map(a => a.copy()).unwrap_or([]),
files: files.map(a => a.copy()).unwrap_or([]),
}
}
///|
/// The first value under this name.
pub fn Form::field(self : Form, name : StringView) -> String? {
for field in self.fields {
if field.name[:] == name {
return Some(field.value)
}
}
None
}
///|
/// Every value under this name — a set of checkboxes sends the same name many
/// times, and taking only the first silently drops the rest.
pub fn Form::values(self : Form, name : StringView) -> Array[String] {
let out : Array[String] = []
for field in self.fields {
if field.name[:] == name {
out.push(field.value)
}
}
out
}
///|
/// The first file under this name.
pub fn Form::file(self : Form, name : StringView) -> Upload? {
for upload in self.files {
if upload.name[:] == name {
return Some(upload)
}
}
None
}
///|
/// Every field, in the order they were sent.
pub fn Form::entries(self : Form) -> Array[Field] {
self.fields
}
///|
/// Every file, in the order they were sent.
pub fn Form::uploads(self : Form) -> Array[Upload] {
self.files
}
///|
/// Whether anything was parsed at all.
pub fn Form::is_empty(self : Form) -> Bool {
self.fields.length() == 0 && self.files.length() == 0
}
///|
/// Parse a `multipart/form-data` body (RFC 7578) given its boundary.
///
/// A part goes into the files when its `Content-Disposition` carries a
/// `filename` and into the fields when it does not, which is the only thing
/// distinguishing them on the wire.
pub fn formdata(
body : BytesView,
boundary : StringView,
limits~ : Limits = limits,
strict~ : Bool = false,
) -> Form raise Refused {
let fields : Array[Field] = []
let files : Array[Upload] = []
for part in multipart(body, boundary, limits~, strict~) {
let disposition = part.disposition()
let name = match disposition {
Some(d) => d.param("name").unwrap_or("")
None => ""
}
match disposition {
Some(d) =>
match d.filename() {
Some(filename) =>
files.push({
name,
filename,
content_type: part.header("content-type").unwrap_or(""),
content: part.body,
headers: part.headers,
})
None => fields.push({ name, value: text(part.body[:]), })
}
None => fields.push({ name, value: text(part.body[:]), })
}
}
{ fields, files, }
}