6.1 KiB
Multipart
Multipart is a lazy streaming multipart/form-data parser. It reads from req.body().stream()
— the request body is never fully materialised. Text fields are buffered eagerly (they are
small by definition); file part bodies are exposed as zero-copy InputStreams backed directly
by the socket.
Creating a parser
Multipart mp = Multipart.of(req);
Throws IllegalArgumentException if the request is not multipart/form-data or the
boundary parameter is missing. Throws IOException if the initial stream read fails (the
parser skips the opening --boundary\r\n preamble on construction).
Reading text fields — field(String name)
String userId = mp.field("userId"); // null if absent or a file part
String width = mp.field("width");
Scans forward through the stream until the named text field is found. Text parts encountered along the way are buffered eagerly. File bodies encountered along the way are drained silently (discarded without heap allocation).
Order-independent for text: once a text field has been scanned, it is cached. Calling
mp.field("userId") after mp.field("width") will hit the cache if userId was encountered
first in the stream.
Returns null if the field is absent or if the named part is a file (has a filename
attribute). Never returns file content.
Reading file parts — file(String name)
Part avatar = mp.file("avatar"); // null if absent or a text field
Scans forward until the named file part is found. Text parts encountered along the way are
buffered (accessible later via field()). Earlier file bodies encountered along the way are
drained silently.
The returned Part's stream is backed by the socket. Consume it before calling any other
scan method — calling mp.field(), mp.file(), or mp.parts() will drain the active
stream automatically, but any bytes already read are gone.
Returns null if absent or if the named part is a text field.
Collecting multiple parts — parts(String name) and parts()
List<Part> files = mp.parts("files"); // all parts named "files"
List<Part> all = mp.parts(); // every part in declaration order
Forces a full scan. All file bodies are materialised into byte[] arrays — accept this
heap cost explicitly when calling these methods. Useful for bulk uploads where you need all
files before processing any of them.
parts() must be called before any file() call if you need all file parts, since a previous
file() call may have consumed some of them from the stream.
The Part object
Every part — whether text or file — is returned as a Part.
Metadata
part.name() // field name from Content-Disposition
part.filename() // original filename, null for text fields
part.contentType() // declared Content-Type, null if not present
part.isFile() // true when filename() != null
Body access
// Zero-copy stream — the default
InputStream in = part.stream();
// Explicit materialisation — opt-in heap allocation
byte[] data = part.materialize();
// UTF-8 convenience — materialises if needed, result is cached
String text = part.text();
stream()
For buffered parts (text fields and parts() results): returns a fresh
ByteArrayInputStream over the cached byte[]. Repeatable, no I/O.
For streaming file parts (from file()): returns the raw bounded socket InputStream.
Read it once only, and before requesting the next part.
materialize()
Reads the full part body into a byte[] and caches it. Safe to call multiple times on
buffered parts (returns the same array). For streaming file parts, triggers a full socket read
on the first call; subsequent calls return the cached array.
text()
Convenience over materialize(). Decodes the body as UTF-8. Result is cached.
Memory model
| Operation | Heap cost |
|---|---|
Multipart.of(req) |
8 KB window buffer + boundary bytes |
mp.field("x") |
body of the text field only (~bytes of the value) |
mp.file("f") |
none — body is a bounded socket stream |
part.stream() on a file |
none |
part.materialize() on a file |
full file size |
mp.parts() |
full body of every part |
| Draining a file body (implicit) | 8 KB reused drain buffer |
Ordering constraints and safe patterns
Pattern 1 — text fields + one file (most common)
Text fields are accessible in any order. The file is streamed zero-copy.
Multipart mp = Multipart.of(req);
String userId = mp.field("userId"); // order doesn't matter for text
String label = mp.field("label");
Part file = mp.file("file"); // stream it after fields
Files.copy(file.stream(), destination, StandardCopyOption.REPLACE_EXISTING);
return res.body("saved " + file.filename());
Pattern 2 — echo large file (zero heap)
Part file = Multipart.of(req).file("file");
return res.chunked(file.stream()); // socket-in → chunked-out, never touches heap
Pattern 3 — bulk upload (accept the heap cost)
List<Part> files = Multipart.of(req).parts("files");
for (Part f : files) {
Files.write(dir.resolve(f.filename()), f.materialize());
}
Pattern 4 — mixed order (file before text in the form)
If the form declares the file before a text field and you need the text first,
field() will drain the file body silently to reach the text part. The file is then gone.
Either reorder the form fields or use parts() if you need both.
// Form order: file, then userId
// This works — file is drained silently, userId is buffered
String userId = mp.field("userId");
// But now mp.file("file") returns null — already drained
If you need both, use parts() upfront:
List<Part> all = mp.parts();
Part file = all.stream().filter(Part::isFile).findFirst().orElse(null);
String userId = all.stream().filter(p -> "userId".equals(p.name())).findFirst()
.map(p -> { try { return p.text(); } catch (IOException e) { throw new UncheckedIOException(e); } })
.orElse(null);