///|
/// The form and file extractors — FastAPI's `Form(...)` / `File(...)`. One
/// `Context::form` parses both carriers: an `application/x-www-form-urlencoded`
/// body (percent- and `+`-decoded) and a `multipart/form-data` body split on its
/// boundary. `Form::field` reads the first value, `values` every value a
/// repeated field submitted, and `file` an `Upload` — filename, content-type,
/// and the raw bytes byte-for-byte, so a binary upload survives untouched.
/// `@moonmime.Limits` bounds what a sender may spend: a body over the limit comes back
/// as `None` and is answered `413` rather than parsed.
///
/// moon run src/examples/13-forms-files
#coverage.skip
async fn main {
let app = @moonapi.App::new()
app.post("/upload", ctx => {
// A title and three tags is the whole of this form, so more is not it.
let form = match ctx.form(limits={ ..@moonmime.limits, parts: 4, }) {
Some(f) => f
None =>
return @moonapi.text(
@moonapi.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
"too many form parts",
)
}
let title = form.field("title").unwrap_or("-")
let tags = form.values("tag")
match form.file("doc") {
Some(f) =>
@moonapi.text(
200,
"title=\{title} tags=[\{join_csv(tags)}] file=\{f.filename} type=\{f.content_type} bytes=\{f.content.length()} body=\{@utf8.decode_lossy(f.content[:])}",
)
None =>
@moonapi.text(200, "title=\{title} tags=[\{join_csv(tags)}] (no file)")
}
})
let post = (label : String, ct : String, body : Bytes) => {
let req : @moonasgi.Request = {
http_method: "POST",
path: "/upload",
query_string: b"",
headers: [("content-type", ct)],
body,
}
let r = app.handle(req)
println("\{label} -> \{r.status} \{@utf8.decode_lossy(r.body[:])}")
}
// 1. urlencoded: a repeated `tag` becomes a list; `+` decodes to a space.
post(
"urlencoded",
"application/x-www-form-urlencoded",
@utf8.encode("title=hello+world&tag=a&tag=b&tag=c"),
)
// 2. multipart: a text field plus an uploaded file with its own content-type.
let boundary = "boundaryqwq233"
let multipart =
#|--boundaryqwq233
#|Content-Disposition: form-data; name="title"
#|
#|greetings
#|--boundaryqwq233
#|Content-Disposition: form-data; name="doc"; filename="hello.txt"
#|Content-Type: text/plain
#|
#|qwq file bytes 23333
#|--boundaryqwq233--
#|
// The multipart grammar is CRLF-delimited; the raw string above is LF-only.
let crlf_body = @utf8.encode(to_crlf(multipart))
post("multipart", "multipart/form-data; boundary=" + boundary, crlf_body)
// 3. A fifth pair is over the limit, and the form is refused whole.
post(
"over-limit",
"application/x-www-form-urlencoded",
@utf8.encode("title=a&tag=b&tag=c&tag=d&tag=e"),
)
}
///|
/// Join strings with commas — a repeated form field surfaces as a list.
fn join_csv(xs : Array[String]) -> String {
let sb = StringBuilder()
for i = 0; i < xs.length(); i = i + 1 {
if i > 0 {
sb.write_string(",")
}
sb.write_string(xs[i])
}
sb.to_string()
}
///|
/// Rewrite LF line endings as CRLF — the multipart wire grammar is CRLF, but a
/// MoonBit multi-line string literal carries bare LFs.
fn to_crlf(s : String) -> String {
let sb = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == 0x0A {
sb.write_string("\r\n")
} else {
sb.write_char(s[i].unsafe_to_char())
}
}
sb.to_string()
}