From one header to a whole upload.
Each section builds on the last. Every block is a real program and every output below it is what it printed.
Install
Add the package to the module, then import it in the package that uses it.
moon add moonbitstack/moonmime
import {
"moonbitstack/moonmime",
}
Now parse a Content-Type and read the pieces back.
let mt = @moonmime.media_type("text/html; charset=utf-8")
println(mt.essence)
println(mt.type_())
println(mt.param("charset").unwrap_or(""))
Media types
media_type splits a header at its first ;. The essence is lowercased; the parameters keep their order and their case. A value it cannot make sense of is dropped rather than guessed at.
let mt = @moonmime.media_type("Text/HTML; charset=UTF-8")
println(mt.essence)
println(mt.type_())
println(mt.subtype())
println(mt.param("charset").unwrap_or(""))
println(mt.to_string())
A parameter value that is not a token goes back out quoted, so a boundary with spaces stays in one piece:
let mt = @moonmime.media_type("multipart/form-data; boundary=a b")
println(mt.param("boundary").unwrap_or(""))
println(mt.to_string())
When the value decides what a file is, read it strictly instead. MediaType::parse follows the MIME Sniffing standard: a type/subtype whose halves are tokens, parameters as ;name=value, and nothing else. A value it cannot read comes back as None, not as an empty record.
let strict = @moonmime.MediaType::parse("Text/HTML ; charset=\"utf-8\"; charset=latin1")
println(strict.unwrap().to_string())
println(strict.unwrap().param("charset").unwrap_or(""))
println(@moonmime.MediaType::parse("text/html, text/plain") is None)
The first of two parameters with the same name wins. A parameter whose value cannot be written back into a quoted string is dropped rather than carried, and a trailing ; is forgiven — text/html; is not a valid media type string, but it parses as one.
media_type keeps what it can so a caller needs no branch on a header it did not write; MediaType::parse answers whether the value is a media type at all.Filenames and types
guess_type maps a filename to the media type its last extension implies; guess_extension goes the other way and gives the extension people actually use. The last dot wins, so a compound extension resolves on its tail.
println(@moonmime.guess_type("tacos-al-pastor.png"))
println(@moonmime.guess_type("factura.PDF"))
println(@moonmime.guess_type("archive.tar.gz"))
println(@moonmime.guess_type("name.unheard-of"))
println(@moonmime.guess_extension("audio/mpeg").unwrap_or(""))
println(@moonmime.guess_extension("text/plain; charset=utf-8").unwrap_or(""))
Anything the registry has not heard of is application/octet-stream — the value fallback holds. A type with no common extension returns None.
Naming a download
content_disposition writes the header that names a file for the browser. A plain ASCII name stays quoted. A name with a quote, a newline, or a byte outside ASCII goes out as filename* (RFC 5987), which a browser reads back whole.
println(@moonmime.content_disposition("menu.pdf"))
println(@moonmime.content_disposition("receta \"de la abuela\".pdf"))
println(@moonmime.content_disposition("menu.pdf", inline=true))
On the way in, parse the header and ask for the filename. filename* wins over filename; a filename* that is not UTF-8 falls back to the plain one.
let d = @moonmime.Disposition::parse(
"attachment; filename=\"fallback.txt\"; filename*=utf-8''r%C3%A9sum%C3%A9.txt",
)
println(d.kind)
println(d.param("filename").unwrap_or(""))
println(d.filename().unwrap_or(""))
Multipart
A multipart body is a boundary, a series of parts, and a closing boundary. multipart takes the body as bytes and the boundary without its leading dashes. Preamble and epilogue are dropped; the CRLF before each delimiter belongs to the delimiter, not the part.
///| A string literal as the bytes a request would carry.
fn body(text : String) -> Bytes {
let out : Array[Byte] = []
for i = 0; i < text.length(); i = i + 1 {
out.push((text.at(i).to_int() & 0xFF).to_byte())
}
Bytes::from_array(out)
}
let mail = body("--b\r\nContent-Type: text/plain\r\n\r\nhola\r\n--b--\r\n")
let parts = @moonmime.multipart(mail[:], "b")
println(parts.length())
println(parts[0].header("content-type").unwrap_or(""))
println(parts[0].body.length())
println(parts[0].content_type().unwrap().essence)
Each Part keeps its own headers and its body whole. content_type and disposition parse the two headers a form part carries.
Form data
formdata reads the same bytes as a form. A part with a filename in its Content-Disposition becomes an upload; a part without one becomes a text field. That is the only thing distinguishing them on the wire.
let upload = "------edge\r\n" +
"Content-Disposition: form-data; name=\"title\"\r\n\r\n" +
"a report\r\n" +
"------edge\r\n" +
"Content-Disposition: form-data; name=\"tags\"\r\n\r\none\r\n" +
"------edge\r\n" +
"Content-Disposition: form-data; name=\"tags\"\r\n\r\ntwo\r\n" +
"------edge\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"notes.txt\"\r\n" +
"Content-Type: text/plain\r\n" +
"X-Checksum: abc\r\n\r\n" +
"line one\r\nline two\r\n" +
"------edge--\r\n"
let form = @moonmime.formdata(body(upload)[:], "----edge")
println(form.field("title").unwrap_or(""))
for tag in form.values("tags") {
println(tag)
}
let file = form.file("file").unwrap()
println(file.filename)
println(file.content_type)
println(file.size())
println(file.header("x-checksum").unwrap_or(""))
values keeps every value under a name — a set of checkboxes sends one name many times, and taking only the first silently drops the rest. An upload keeps its own headers, so a part's checksum or encoding is still there.
Writing a body
The reader's mirror: Form::encode frames the same shape it takes. Fields go out first, then files, each with the Content-Disposition that makes it one or the other. Give it no boundary and it picks one — a fixed stem, stepped on until it cannot stand inside any part, so the same form always comes out as the same octets.
let fields : Array[@moonmime.Field] = [
{ name: "title", value: "a report" },
{ name: "tags", value: "one" },
{ name: "tags", value: "two" },
]
let form = @moonmime.Form::new(fields~)
let written = form.encode(boundary="----edge")
println(written.content_type())
// The octets as text, to look at them: `moonbitlang/core/encoding/utf8`.
println(@utf8.decode_lossy(written.body[:]))
Every break in that dump is a CRLF: the delimiter line is preceded by one, and the two characters are part of the framing rather than of any part's body. Read the octets back with formdata and the boundary it names, and the same fields come out.
A boundary the caller names is checked before it is written: it has to be RFC 2046's bchars and no more than 70 of them, and it cannot already stand inside a part, where a receiver would split on it. The generated one never has that problem — a part that already holds the candidate only moves the count on.
let bumped = @moonmime.Form::new(
fields=[{ name: "note", value: "------moonmime" }],
).encode()
println(bumped.content_type())
A filename that leaves ASCII goes out as filename* (RFC 5987), the same rule content_disposition follows: percent-encoded UTF-8, the one form a receiver decodes back to the characters that went in, and the one a name cannot break out of.
let named = @moonmime.Form::new(
files=[
{
name: "doc",
filename: "résumé.pdf",
content_type: "application/pdf",
content: b"%PDF",
headers: [],
},
],
).encode(boundary="----edge")
let part = @moonmime.multipart(named.body[:], named.boundary)[0]
println(part.header("content-disposition").unwrap_or(""))
For a body that is not a form, Multipart::new takes parts and frames them as multipart/mixed — the shape an email with an attachment has. MultipartKind says which subtype the header carries, and content_type() writes it with the boundary in place.
let mail = @moonmime.Multipart::new([
{ headers: [("content-type", "text/plain")], body: b"hola" },
{ headers: [("content-type", "text/html")], body: b"hola" },
])
println(mail.content_type())
println(@utf8.decode_lossy(mail.body[:]))
Reading as it arrives
Everything above hands a whole body over at once. A body off a socket is not like that, and neither is one too large to want in memory. PartsReader takes it as it comes: a part's header fields first, then its body a chunk at a time, so a caller can decide it does not want a part before a single octet of it is read.
let single = "------edge\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\na report\r\n------edge--\r\n"
let source : &@moonmime.Source = @moonmime.Chunks::new(body(single)[:])
let reader = @moonmime.PartsReader::new(source, "----edge")
let head = reader.next_part().unwrap()
println(head.header("content-disposition").unwrap_or(""))
println(@utf8.decode_lossy(reader.read().unwrap()))
println(reader.next_part() is None)
Source is the whole of the contract: one method, next, answering the next piece and None at the end. Chunks is the adapter for a body already in hand; a socket reads into it with a few lines of its own. PartsReader::over(body, boundary) is the same reader without the adapter.
The bound holds while reading rather than after it. A part that passes its size is refused where it is crossed, and the octets behind it are never pulled — a body of a gigabyte fails after a megabyte, not after a gigabyte. A part the caller abandons is discarded before the next head, and still counts.
The way out is MultipartWriter: it hands the delimiter, the header block and the body octets to a Sink as it is given them, so nothing larger than a chunk is ever held. Gathered is a sink that keeps the octets, for a caller that wants them after all.
let sink = @moonmime.Gathered::new()
let writer = @moonmime.MultipartWriter::new(sink, boundary="----edge")
writer.begin_part([("content-type", "text/plain")])
writer.body(b"hola")
writer.begin_part([("content-type", "text/html")])
writer.body(b"<b>hola</b>")
writer.close()
println(writer.content_type())
println(@utf8.decode_lossy(sink.bytes()[:]))
A form is a list of parts, so Form::parts() is what a caller streams: same headers, same order, one part at a time. The octets are the ones Form::encode writes for the same boundary, and the interop check holds the two to be equal byte for byte.
Bounds and strict reading
Both multipart and formdata take Limits — a thousand parts of a megabyte each by default. A body over its bound is refused whole, never truncated, because a handler given the first part of a larger body would be answering a request nobody sent.
try {
let limited = @moonmime.formdata(
body(upload)[:], "----edge", limits=@moonmime.Limits::new(parts=2),
)
println(limited.is_empty())
} catch {
@moonmime.Parts(limit~) => println("refused: more than " + limit.to_string() + " parts")
_ => println("refused")
}
A body that stops making sense returns the parts read so far. strict turns that into Malformed at the byte where reading gave up.
let truncated = "------b\r\nContent-Disposition: form-data; name=\"a\"\r\n"
try {
ignore(@moonmime.formdata(body(truncated)[:], "----b", strict=true))
} catch {
@moonmime.Malformed(at~) => println("malformed at byte " + at.to_string())
_ => println("refused")
}
Limits caps both.