moonpart

Streaming-oriented multipart/form-data parser and encoder for MoonBit.

multipart
form-data
http
upload
rfc7578
Download zip
Author
Version
0.1.0
License
Apache-2.0
Last updated
15 hours ago
Downloads
2

#MoonPart

CI License

MoonPart is a pure MoonBit multipart/form-data core library. It implements a small, strict RFC 7578-oriented parser and encoder for HTTP form bodies.

#Installation

After the 0.1.0 release is published to mooncakes.io:

moon add eisem/moonpart@0.1.0

Before publication, clone this repository and run the example and tests from the repository root.

#Current status

The library includes both a convenient text collector and StreamingParser, a byte-preserving event parser. StreamingParser::feed_bytes accepts arbitrary network chunks and emits PartBegin, PartData, PartEnd, and Finished events as soon as they are known. It keeps only a possible-boundary suffix, rather than retaining whole uploaded files.

StreamingEncoder performs the inverse operation. It converts the same events into immediately writable Bytes chunks and passes PartData through without text conversion or whole-body buffering.

StreamLimits bounds the complete wire body, header blocks, number of parts, and each part body. This is suitable for HTTP handlers that must reject hostile or accidental oversized uploads early.

Each decoded Part retains headers and exposes name, filename, is_file, and content_type convenience accessors. Missing part Content-Type values use the RFC 7578 text/plain default.

For uploaded files, prefer decode_bytes. It returns StreamEvent values and keeps every part body as Bytes, so arbitrary binary data is preserved:

///|
test "read a binary file field" {
let body = b"--bin\r\nContent-Disposition: form-data; name=\"file\"; filename=\"raw.bin\"\r\n\r\n\x00\xff\r\n--bin--\r\n"
match @moonpart.decode_bytes("bin", body) {
Ok(
[
@moonpart.StreamEvent::PartBegin(_),
@moonpart.StreamEvent::PartData(data),
..,
]
) => assert_eq(data.length(), 2)
_ => fail("invalid multipart body")
}
}

#Example

///|
test "parse one field" {
let parser = @moonpart.Parser::new("MyBoundary")
ignore(
parser.feed(
"--MyBoundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMoonBit\r\n--MyBoundary--\r\n",
),
)
match parser.finish() {
Ok(parts) => println(parts[0].body)
Err(_) => println("invalid multipart body")
}
}

#Streaming an upload

///|
test "handle an upload incrementally" {
let parser = @moonpart.StreamingParser::from_content_type(
"multipart/form-data; boundary=upload",
)
guard parser is Ok(parser) else { fail("invalid content type") }
let chunks = [
b"--upload\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\n\r\nabc",
b"\r\n--upload--\r\n",
]
for chunk in chunks {
guard parser.feed_bytes(chunk) is Ok(events) else {
fail("invalid multipart body")
}
for event in events {
match event {
@moonpart.StreamEvent::PartData(data) =>
// Write data to a file, socket, or other application sink here.
assert_true(data.length() > 0)
_ => ()
}
}
}
assert_true(parser.finish() is Ok(_))
}

For small forms, feed the same events to FormDataCollector; it preserves the wire order and repeated field names while exposing text fields and raw file bytes. Its configurable per-part memory limit makes its buffering explicit.

Submitted filenames are untrusted input. filename_basename removes Unix and Windows directory components, but applications should still generate their own server-side storage paths rather than writing directly to a submitted name.

For large uploads, use StreamingFormCollector. Its callback receives FileSinkEvent::Begin, incremental Data(Bytes) chunks, and End. The collector retains text fields and file metadata only; applications can forward file chunks directly to a filesystem, object store, hash function, or network sink without constructing the complete file in memory.

StreamingFormDecoder connects content-type boundary parsing, incremental body decoding, limits, field validation, and sink delivery behind one feed_bytes API for HTTP handlers.

Use FieldSizeLimit when a specific field needs a stricter cap than the global text or file limit, for example a short title alongside a larger asset.

#Streaming an encoded body

Create StreamEvent values in wire order and write each returned chunk directly to an HTTP request body, file, or application sink:

///|
test "encode binary data incrementally" {
let encoder = @moonpart.StreamingEncoder::new("out")
let chunks = []
let events = [
@moonpart.StreamEvent::PartBegin([
@moonpart.Header::{
name: "Content-Disposition",
value: "form-data; name=\"file\"; filename=\"a.bin\"",
},
]),
@moonpart.StreamEvent::PartData(b"\x00\xff"),
@moonpart.StreamEvent::PartEnd,
@moonpart.StreamEvent::Finished,
]
for event in events {
guard encoder.push(event) is Ok(chunk) else {
fail("invalid encoder event")
}
chunks.push(chunk)
}
assert_true(chunks[1] == b"\x00\xff")
assert_true(chunks[3] == b"--out--\r\n")
}

StreamingEncoder::content_type returns the matching quoted Content-Type value. Invalid boundaries, unsafe headers, and out-of-order events are rejected; after an error the encoder remains failed.

#Choosing an API

Use caseAPIBody storage
Small text-oriented formsParserComplete body as String
Binary body decoded at oncedecode_bytesEvents contain raw Bytes
Incremental protocol parsingStreamingParserPossible-boundary suffix only
Small forms collected in memoryFormDataCollectorText and file bodies
Large files sent to a sinkStreamingFormDecoderText fields and file metadata
Incremental binary encodingStreamingEncoderCurrent header chunk only

Run the complete streaming example with:

moon run cmd/main

#Scope and non-goals

MoonPart owns multipart/form-data boundary parsing, strict part-header parsing, encoding, resource limits, form collection, and sink-oriented file delivery. It deliberately does not provide an HTTP server/client, asynchronous filesystem I/O, temporary-file management, MIME sniffing, antivirus scanning, or trusted storage paths. Integrations should supply request chunks and decide how emitted file bytes are persisted.

The current release targets RFC 7578 form-data behavior. It is not a general recursive MIME message parser and does not decode content-transfer encodings.

#Security

All limits should be selected for the surrounding HTTP service. Submitted filenames, media types, headers, and text values remain untrusted. See SECURITY.md for the supported security model and vulnerability reporting process.

#Development

moon fmt moon info moon check --target all moon build --target all moon test --target all moon coverage analyze moon bench --target native moon run cmd/main

Continuous integration checks formatting and generated interfaces, then builds and tests the Wasm, Wasm-GC, JavaScript, and native backends. It also runs MoonBit's coverage analyzer on every push and pull request.

The implementation design is documented in docs/DESIGN.md. See CONTRIBUTING.md for the change workflow and CHANGELOG.md for release notes. The latest reproducible competition checklist is in docs/ACCEPTANCE.md.

The implementation is original and takes RFC 7578 and RFC 2046 as behavioral references. It is licensed under Apache-2.0.

MultipartError

pub(all) suberror MultipartError {
InvalidBoundary
InvalidContentType
BodyTooLarge(Int)
MissingOpeningBoundary
MissingHeaderTerminator
InvalidHeader(String)
DuplicateHeader(String)
MissingContentDisposition
InvalidContentDisposition
MissingFieldName
HeaderTooLarge(Int)
HeaderCountExceeded(Int)
PartCountExceeded(Int)
PartTooLarge(Int)
InvalidStreamEvent
FieldTooLarge(Int)
FileTooLarge(Int)
FieldCountExceeded(Int)
FileCountExceeded(Int)
UnexpectedField(String)
SinkFailure(String)
MissingClosingBoundary
UnexpectedBoundary
} derive(
Debug
)

Errors reported when a multipart body is malformed or exceeds its limit.

EncoderState

type EncoderState

FieldSizeLimit

pub(all) struct FieldSizeLimit {
name : String
max_size : Int
} derive(Eq,
Debug
)

A size override for one named form field. It applies to both text and file parts and can only make the corresponding global limit stricter.

FileSinkEvent

pub(all) enum FileSinkEvent {
Begin(StreamedFile)
Data(Bytes)
End
} derive(
Debug
)

Events sent to an application-provided file sink. Each Begin is followed by zero or more Data events and exactly one End event.

FormData

pub(all) struct FormData {
entries : Array[FormEntry]
} derive(
Debug
)

Collected multipart/form-data entries in original wire order.

FormData::file

fn FormData::file(self : FormData, name : String) -> UploadedFile?

Returns the first uploaded file with the supplied field name.

FormData::files

fn FormData::files(self : FormData, name : String) -> Array[UploadedFile]

Returns every uploaded file with the supplied field name in wire order.

FormData::text

fn FormData::text(self : FormData, name : String) -> String?

Returns the first text value with the supplied field name.

FormData::text_all

fn FormData::text_all(self : FormData, name : String) -> Array[String]

Returns every text value with the supplied field name in wire order.

FormDataCollector

pub struct FormDataCollector {
entries : Array[FormEntry]
buffer :
Buffer

limits : FormDataLimits
allowed_fields : Array[String]?
field_size_limits : Array[FieldSizeLimit]
current_part : FormPartMetadata?
current_size : Int
field_count : Int
file_count : Int
finished : Bool
}

Event consumer that builds an in-memory FormData value from a StreamingParser. Use a future sink-based collector for very large files.

FormDataCollector::finish

Returns the final collected data once the Finished event has been consumed.

FormDataCollector::new

FormDataCollector::push

fn FormDataCollector::push(self : FormDataCollector, event : StreamEvent) -> Result[Unit, MultipartError]

Consumes one streaming event.

FormDataCollector::with_allowed_fields

fn FormDataCollector::with_allowed_fields(allowed_fields : Array[String], limits? : FormDataLimits) -> FormDataCollector

Creates a collector that rejects any part whose form field name is not in allowed_fields. Repeated occurrences of an allowed name remain valid.

FormDataCollector::with_field_size_limits

fn FormDataCollector::with_field_size_limits(limits : FormDataLimits, field_size_limits : Array[FieldSizeLimit]) -> FormDataCollector

Creates a collector with global limits plus stricter per-field size limits.

FormDataCollector::with_file_size_limit

fn FormDataCollector::with_file_size_limit(max_file_size : Int) -> FormDataCollector

Creates an in-memory collector with a maximum size for one part body.

FormDataCollector::with_limits

Creates an in-memory collector with separate text-field and file limits.

FormDataLimits

pub(all) struct FormDataLimits {
max_field_size : Int
max_file_size : Int
max_field_count : Int
max_file_count : Int
}

Resource bounds for the in-memory form collector. Text and file limits are separate because applications commonly accept many small fields but only a small number of larger uploads.

FormDataLimits::default

FormEntry

pub(all) enum FormEntry {
Text(TextField)
File(UploadedFile)
} derive(
Debug
)

A form entry in original wire order. Repeated names are intentionally kept.

FormPartMetadata

type FormPartMetadata

Validated form-data metadata cached for the lifetime of one part.
pub(all) struct Header {
name : String
value : String
} derive(Eq,
Debug
)

A header belonging to one multipart body part. Header names are preserved as received; use Part::header for case-insensitive lookup.

Parser

pub struct Parser {
boundary : String
max_body_size : Int
max_part_count : Int
max_header_size : Int
max_header_count : Int
buffer : String
failed : MultipartError?
}

An incremental multipart collector. Chunks may be supplied at arbitrary boundaries; parsing happens on finish so a delimiter split across chunks is handled exactly like an unsplit delimiter.

Parser::feed

fn Parser::feed(self : Parser, chunk : String) -> Result[Unit, MultipartError]

Adds a body chunk. Chunks can be split at any character boundary.

Parser::finish

fn Parser::finish(self : Parser) -> Result[Array[Part], MultipartError]

Parses all supplied chunks and returns body parts in wire order.

Parser::from_content_type

fn Parser::from_content_type(content_type : String, max_body_size? : Int) -> Result[Parser, MultipartError]

Creates a parser directly from an HTTP Content-Type value.

Parser::new

fn Parser::new(boundary : String, max_body_size? : Int) -> Parser

Creates a parser for the supplied boundary. The boundary must be the raw value from the Content-Type parameter, without the leading --.

Parser::with_limits

fn Parser::with_limits(boundary : String, max_body_size? : Int, max_part_count? : Int, max_header_size? : Int, max_header_count? : Int) -> Parser

Creates a parser with explicit resource limits. The parser rejects a body once any configured bound is exceeded.

Part

pub(all) struct Part {
headers : Array[Header]
body : String
} derive(Eq,
Debug
)

One decoded multipart body part. body is text in this first core API; the parser never interprets its contents.

Part::content_type

fn Part::content_type(self : Part) -> String

Returns the part media type. RFC 7578 defaults an omitted Content-Type to text/plain for form fields.

Part::disposition_parameter

fn Part::disposition_parameter(self : Part, parameter : String) -> String?

Returns a parameter from Content-Disposition, such as name or filename. Quoted parameter values are unquoted.

Part::filename

fn Part::filename(self : Part) -> String?

Convenience accessor for the submitted file name, when present.

Part::filename_basename

fn Part::filename_basename(self : Part) -> String?

Returns the submitted filename without directory components.

Part::header

fn Part::header(self : Part, name : String) -> String?

Returns a header value using ASCII case-insensitive comparison.

Part::is_file

fn Part::is_file(self : Part) -> Bool

Reports whether this part carries a submitted file name.

Part::name

fn Part::name(self : Part) -> String?

Convenience accessor for the form field name.

StreamEvent

pub(all) enum StreamEvent {
PartBegin(Array[Header])
PartData(Bytes)
PartEnd
Finished
} derive(
Debug
)

Events emitted by the binary multipart decoder. Part bodies remain Bytes, so file contents are never decoded as text.

StreamLimits

pub(all) struct StreamLimits {
max_body_size : Int
max_part_count : Int
max_header_size : Int
max_header_count : Int
max_part_size : Int
}

Resource bounds for the incremental decoder. They mirror the limits that production multipart readers use to prevent unbounded header or body buffering.

StreamLimits::default

fn StreamLimits::default() -> StreamLimits

StreamState

type StreamState derive(
Debug
)

Internal state of an incremental binary multipart decoder.

StreamedFile

pub(all) struct StreamedFile {
field_name : String
filename : String
content_type : String
headers : Array[Header]
} derive(Eq,
Debug
)

Metadata for a streamed file part. File contents are delivered separately as FileSinkEvent::Data and are never retained by StreamingFormCollector.

StreamedFile::filename_basename

fn StreamedFile::filename_basename(self : StreamedFile) -> String

Returns the submitted filename without Unix or Windows directory parts.

StreamedFormData

pub(all) struct StreamedFormData {
fields : Array[TextField]
files : Array[StreamedFile]
} derive(
Debug
)

Result of sink-based form collection. Text fields are retained in memory; files contain metadata only because their bytes have already reached the sink.

StreamingEncoder

pub struct StreamingEncoder {
boundary : String
state : EncoderState
failed : MultipartError?
}

Incrementally converts multipart events into binary wire chunks. The encoder retains only its state and the boundary; returned chunks can be written directly to an HTTP body sink.

StreamingEncoder::content_type

fn StreamingEncoder::content_type(self : StreamingEncoder) -> String

Returns a Content-Type value suitable for the encoded body.

StreamingEncoder::from_content_type

fn StreamingEncoder::from_content_type(content_type : String) -> Result[StreamingEncoder, MultipartError]

Creates an encoder from an HTTP multipart/form-data Content-Type value.

StreamingEncoder::new

fn StreamingEncoder::new(boundary : String) -> StreamingEncoder

Creates an encoder for a raw multipart boundary. Invalid boundaries are reported by the first push call.

StreamingEncoder::push

fn StreamingEncoder::push(self : StreamingEncoder, event : StreamEvent) -> Result[Bytes, MultipartError]

Encodes one event. PartBegin emits a boundary and headers, PartData returns the original binary payload without text conversion, PartEnd emits the required CRLF, and Finished emits the closing boundary.

Events must follow PartBegin, zero or more PartData, PartEnd, then another part or Finished. Any error is terminal.

StreamingFormCollector

pub struct StreamingFormCollector {
fields : Array[TextField]
files : Array[StreamedFile]
text_buffer :
Buffer

limits : FormDataLimits
allowed_fields : Array[String]
field_size_limits : Array[FieldSizeLimit]
sink : (FileSinkEvent) -> Result[Unit, MultipartError]
current_part : FormPartMetadata?
current_size : Int
field_count : Int
file_count : Int
finished : Bool
failed : MultipartError?
}

Consumes multipart events while forwarding file bytes to a callback. An empty allowed_fields list accepts every field name.

StreamingFormCollector::finish

Returns collected text fields and streamed file metadata after Finished.

StreamingFormCollector::new

fn StreamingFormCollector::new(sink : (FileSinkEvent) -> Result[Unit, MultipartError], limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> StreamingFormCollector

StreamingFormCollector::push

fn StreamingFormCollector::push(self : StreamingFormCollector, event : StreamEvent) -> Result[Unit, MultipartError]

Consumes one event produced by StreamingParser.

StreamingFormDecoder

pub struct StreamingFormDecoder {
parser : StreamingParser
collector : StreamingFormCollector
failed : MultipartError?
}

End-to-end multipart/form-data decoder that connects StreamingParser to a StreamingFormCollector. File bytes go directly to the supplied sink.

StreamingFormDecoder::feed_bytes

fn StreamingFormDecoder::feed_bytes(self : StreamingFormDecoder, chunk : Bytes) -> Result[Unit, MultipartError]

Feeds an arbitrary network body chunk through the parser and file sink.

StreamingFormDecoder::finish

Validates the closing boundary and returns text fields plus file metadata.

StreamingFormDecoder::from_content_type

fn StreamingFormDecoder::from_content_type(content_type : String, sink : (FileSinkEvent) -> Result[Unit, MultipartError], stream_limits? : StreamLimits, form_limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> Result[StreamingFormDecoder, MultipartError]

Creates a decoder directly from an HTTP Content-Type value.

StreamingFormDecoder::new

fn StreamingFormDecoder::new(boundary : String, sink : (FileSinkEvent) -> Result[Unit, MultipartError], stream_limits? : StreamLimits, form_limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> StreamingFormDecoder

Creates a decoder from a raw multipart boundary.

StreamingParser

pub struct StreamingParser {
boundary : String
opening : Bytes
marker : Bytes
limits : StreamLimits
pending : Bytes
state : StreamState
received_size : Int
part_count : Int
current_part_size : Int
failed : MultipartError?
}

Incremental multipart decoder. feed_bytes emits completed protocol events as soon as they are known, retaining only a short possible-boundary suffix.

StreamingParser::feed_bytes

fn StreamingParser::feed_bytes(self : StreamingParser, chunk : Bytes) -> Result[Array[StreamEvent], MultipartError]

Feeds one arbitrary body chunk and returns events now known to be complete.

StreamingParser::finish

Finishes the stream. A complete body must already have emitted Finished.

StreamingParser::from_content_type

fn StreamingParser::from_content_type(content_type : String, limits? : StreamLimits) -> Result[StreamingParser, MultipartError]

Creates an incremental decoder from a multipart/form-data Content-Type value, including a quoted boundary parameter.

StreamingParser::new

fn StreamingParser::new(boundary : String) -> StreamingParser

StreamingParser::with_limits

fn StreamingParser::with_limits(boundary : String, limits : StreamLimits) -> StreamingParser

Creates an incremental decoder with explicit bounds for the complete wire body, each header block, the part count, and each individual part body.

TextField

pub(all) struct TextField {
name : String
value : String
} derive(Eq,
Debug
)

A decoded text form field.

UploadedFile

pub(all) struct UploadedFile {
field_name : String
filename : String
content_type : String
data : Bytes
} derive(
Debug
)

A decoded uploaded file. data preserves the original file bytes.

UploadedFile::filename_basename

fn UploadedFile::filename_basename(self : UploadedFile) -> String

Returns the submitted filename without Unix or Windows directory components. Generate a separate server-side storage path for the file.

boundary_from_content_type

fn boundary_from_content_type(content_type : String) -> Result[String, MultipartError]

Extracts and validates the boundary parameter from a multipart/form-data Content-Type header. Quoted boundary values are accepted.

decode_bytes

fn decode_bytes(boundary : String, body : Bytes) -> Result[Array[StreamEvent], MultipartError]

Decodes a complete multipart body into binary-preserving events. This is the one-shot companion to the upcoming incremental byte state machine.

encode

fn encode(boundary : String, parts : Array[Part]) -> String

Encodes parts as a multipart body using the supplied boundary.

filename_basename

fn filename_basename(filename : String) -> String

Returns the submitted filename with both Unix and Windows directory components removed. An empty string is returned for . and ..; callers should still generate their own storage path rather than trusting a name.

Powered by MoonBit

Site sourceReport issuePackagesBuild queueSkillsStatistics

© 2026 mooncakes.io