Guide

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.

shell
moon add moonbitstack/moonmime
moon.pkg
import {
  "moonbitstack/moonmime",
}

Now parse a Content-Type and read the pieces back.

main.mbt
let mt = @moonmime.media_type("text/html; charset=utf-8")
println(mt.essence)
println(mt.type_())
println(mt.param("charset").unwrap_or(""))
text/html text utf-8

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.

main.mbt
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())
text/html text html UTF-8 text/html; charset=UTF-8

A parameter value that is not a token goes back out quoted, so a boundary with spaces stays in one piece:

main.mbt
let mt = @moonmime.media_type("multipart/form-data; boundary=a b")
println(mt.param("boundary").unwrap_or(""))
println(mt.to_string())
a b multipart/form-data; boundary="a b"
Lenient by defaultAn empty or foreign value yields an empty essence instead of an error, so a server can read a header it does not understand without a branch.

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.

main.mbt
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)
text/html; charset=utf-8 utf-8 true

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.

Two readers, one recordmedia_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.

main.mbt
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(""))
image/png application/pdf application/gzip application/octet-stream .mp3 .txt

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.

main.mbt
println(@moonmime.content_disposition("menu.pdf"))
println(@moonmime.content_disposition("receta \"de la abuela\".pdf"))
println(@moonmime.content_disposition("menu.pdf", inline=true))
attachment; filename="menu.pdf" attachment; filename*=utf-8''receta%20%22de%20la%20abuela%22.pdf inline; filename="menu.pdf"

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.

main.mbt
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(""))
attachment fallback.txt résumé.txt

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.

main.mbt — helper
///| 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)
}
main.mbt
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)
1 text/plain 4 text/plain

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.

main.mbt — the body
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"
main.mbt
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(""))
a report one two notes.txt text/plain 18 abc

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.

main.mbt
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[:]))
multipart/form-data; boundary=----edge ------edge Content-Disposition: form-data; name="title" a report ------edge Content-Disposition: form-data; name="tags" one ------edge Content-Disposition: form-data; name="tags" two ------edge--

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.

main.mbt
let bumped = @moonmime.Form::new(
  fields=[{ name: "note", value: "------moonmime" }],
).encode()
println(bumped.content_type())
multipart/form-data; boundary=----moonmime1

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.

main.mbt
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(""))
form-data; name="doc"; filename*=utf-8''r%C3%A9sum%C3%A9.pdf

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.

main.mbt
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[:]))
multipart/mixed; boundary=----moonmime ------moonmime Content-Type: text/plain hola ------moonmime Content-Type: text/html <b>hola</b> ------moonmime--
Control of the boundaryPass one when the receiver already knows it, or when the body has to look like what a browser sends. Leave it out and the library proves one absent from the parts instead of trusting a random draw.

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.

main.mbt
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)
form-data; name="title" a report true

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.

main.mbt
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()[:]))
multipart/mixed; boundary=----edge ------edge Content-Type: text/plain hola ------edge Content-Type: text/html <b>hola</b> ------edge--

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.

What cannot be taken backThe writer watches the octets for its own boundary as they go by, and refuses a part that carries it. By then the sink holds what came before — which is why a caller that cannot lose octets passes a boundary of its own, or writes the body in one call.

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.

main.mbt
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")
}
refused: more than 2 parts

A body that stops making sense returns the parts read so far. strict turns that into Malformed at the byte where reading gave up.

main.mbt
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")
}
malformed at byte 9
Two bounds, both neededA million empty parts and one enormous part are different ways of asking a server to allocate more than it has. Limits caps both.