multipart parsing, request body access, and chunked input stream support

This commit is contained in:
Relism
2026-03-19 12:45:34 +01:00
parent 96afbf665d
commit 16b5f8ac15
17 changed files with 1951 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# Request Body
`req.body()` returns a `RequestBody` — a lazy accessor that exposes the HTTP request body in
two mutually exclusive modes. Choose one per handler based on the payload size and how you
intend to consume it.
---
## Two modes
### `bytes()` — materialise
```java
byte[] body = req.body().bytes();
```
Reads the full body into a `byte[]` and caches the result. Safe to call multiple times — the
second call returns the same cached array, no I/O is performed again.
Use for:
- JSON parsing (`new String(body, UTF_8)` then parse)
- Small form data
- Any payload that must be inspected in full before responding
**Limit:** throws `IllegalStateException` if `Content-Length` exceeds `Integer.MAX_VALUE`
(~2 GB). For larger bodies use `stream()`.
**Chunked bodies:** reads until `ChunkedInputStream` signals EOF, then caches.
---
### `stream()` — zero-copy
```java
InputStream in = req.body().stream();
```
Returns a bounded `InputStream` without allocating the full body upfront.
Use for:
- Large file uploads
- Bodies passed directly to disk, a database, or another stream
- Multipart parsing (via `Multipart.of(req)` which calls this internally)
**Fixed-length bodies:** a `SequenceInputStream` of any pre-buffered header bytes (from the
`RequestParser` read buffer, already in memory) followed by a bounded view of the socket
stream. The small pre-buffered slice costs nothing extra.
**Chunked bodies:** returns the raw `ChunkedInputStream` directly. It de-chunks on the fly —
reads the hex chunk-size line, the data, and the trailing CRLF — and signals EOF at the end
of the last chunk, leaving the socket positioned correctly for the next keep-alive request.
**After `bytes()`:** if the body was already materialised, `stream()` returns a fresh
`ByteArrayInputStream` over the cached array. No I/O is performed.
---
## Mutual exclusivity
```java
// SAFE — pick one mode
byte[] b = req.body().bytes();
InputStream s = req.body().stream();
// UNSAFE — bytes() then stream() is safe (stream() wraps the cache)
// UNSAFE — stream() partially read, then bytes() → undefined results
```
The rule: if you call `stream()` first and partially read it, calling `bytes()` afterwards
will either throw (if `contentLength` > 2 GB) or read only the remaining socket bytes into
the array, missing the already-consumed portion.
---
## Checking the size
```java
long size = req.body().contentLength();
```
- **> 0** — fixed-length body; exact byte count
- **== 0** — empty body (`Content-Length: 0` or no body present)
- **== -1** — `Transfer-Encoding: chunked`; size is unknown upfront
```java
boolean empty = req.body().isEmpty(); // true when contentLength == 0
```
---
## Decision guide
| Payload size | Encoding | Use |
|--------------------|-----------|----------------|
| < 2 GB, fully needed | any | `bytes()` |
| Any size, pass-through | any | `stream()` |
| Multipart form | any | `Multipart.of(req)` (uses `stream()` internally) |
| Size unknown | chunked | `stream()` |
+190
View File
@@ -0,0 +1,190 @@
# 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 `InputStream`s backed directly
by the socket.
---
## Creating a parser
```java
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)`
```java
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)`
```java
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()`
```java
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
```java
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
```java
// 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.
```java
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)
```java
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)
```java
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.
```java
// 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:
```java
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);
```
+104
View File
@@ -0,0 +1,104 @@
# Request
The `Request` object is the immutable view of an incoming HTTP/1.1 request passed to every
handler. It is constructed by `RequestParser`, path/query parameters are injected once by the
router, and then it is handed to your handler unchanged.
```java
server.get("/users/{id}", (req, res) -> { ... });
```
---
## Method and path
```java
HttpMethod method = req.method(); // GET, POST, PUT, …
String path = req.path(); // "/users/42" — decoded UTF-8, no query string
```
`path()` allocates a `String` by decoding the underlying `ByteView`. It is a convenience for
handlers that need the raw path; the router itself never calls it (it works on the `ByteView`
directly for zero-alloc routing).
---
## Headers
```java
String ct = req.header("Content-Type"); // first value, or null
List<String> accepts = req.headers("Accept"); // all values in order
List<String> all = req.headers(); // every header value
```
Lookup is **case-insensitive**: `"content-type"` and `"Content-Type"` resolve to the same header.
`header()` returns `null` if the header is absent. `headers()` returns an empty list.
---
## Path parameters
Path parameters are declared in the route pattern with `{name}` syntax and injected by the
router before the handler runs.
```java
server.get("/users/{id}/orders/{orderId}", (req, res) -> {
String id = req.param("id");
String orderId = req.param("orderId");
});
```
`param()` returns `null` if the route has no such parameter or is not parametric at all.
It never throws.
---
## Query parameters
The query string is parsed lazily on the first `query()` or `queries()` call and cached for
the lifetime of the request.
```java
// GET /search?q=flash&page=2&tag=java&tag=jvm
String q = req.query("q"); // "flash"
String page = req.query("page"); // "2"
List<String> tags = req.queries("tag"); // ["java", "jvm"]
String miss = req.query("absent"); // null
```
`query()` returns the first value for duplicate keys. `queries()` returns all values in
declaration order. Both return `null` / empty list for absent parameters, never throw.
---
## Body
See [BODY.md](BODY.md) for a full reference. Quick summary:
```java
// Small payloads — materialise into byte[]
byte[] json = req.body().bytes();
// Large payloads — stream without heap allocation
InputStream in = req.body().stream();
// Check size before choosing
long size = req.body().contentLength(); // -1 if Transfer-Encoding: chunked
```
The two modes are **mutually exclusive per request**. Calling `stream()` after `bytes()` returns
a `ByteArrayInputStream` over the cached array; calling `bytes()` after `stream()` has been
partially read produces undefined results.
---
## Keep-alive and drain
After the response is fully written to the socket, the server calls `req.drain()` automatically
on keep-alive connections. This discards any unread body bytes so the socket is correctly
positioned for the next pipelined request. You never need to call this yourself.
**Important:** drain happens *after* the response write, not immediately after the handler
returns. This matters for streaming responses that consume the request body on a separate thread
(e.g. a pipe-fed echo): by the time drain runs, the body has already been consumed.
+123
View File
@@ -0,0 +1,123 @@
# Response
The `Response` object is the mutable view of an outgoing HTTP/1.1 response. It is
pre-initialised by the server (`200 / text/plain`) and passed to every handler as `res`.
All mutating methods return `this` for fluent chaining.
```java
server.get("/hello", (req, res) -> res.body("hello"));
server.post("/items", (req, res) -> res.status(HttpStatus.CREATED).body("created"));
```
---
## Status code
```java
res.status(404) // int overload — phrase looked up via HttpStatus.bytesForCode()
res.status(HttpStatus.NOT_FOUND) // enum overload — pre-encoded bytes written directly, zero lookup
```
Prefer the `HttpStatus` overload on hot paths: the pre-encoded `byte[]` is written to the socket
without any lookup. The `int` overload falls back to an O(1) array lookup — still zero allocation,
just one extra indirection.
---
## Content type
```java
res.type(ContentType.JSON) // enum — pre-encoded bytes, zero allocation
res.type("application/octet-stream") // String — encoded once at call time
```
Same pattern as `status`: the `ContentType` enum carries pre-encoded bytes; the `String` overload
encodes once and stores the result.
---
## Body modes
Three mutually exclusive modes. The last one set wins.
### Fixed body
```java
res.body("hello") // String → UTF-8 bytes
res.body(new byte[]{...}) // raw bytes
```
Written with `Content-Length`. The byte array is stored as-is; no copy on the write path.
### Known-length stream
```java
res.stream(Files.newInputStream(path), Files.size(path))
```
Written with `Content-Length`. The stream is drained directly to the socket in the write path —
zero intermediate buffer beyond the OS socket buffer.
### Unknown-length stream (chunked)
```java
res.chunked(source)
```
Written with `Transfer-Encoding: chunked`. The server reads 8 KB at a time from `source`,
writes each chunk with its hex-encoded length, and closes with the terminal `0\r\n\r\n`.
No `Content-Length` header is emitted.
Use when the body length is not known upfront: generated content, pipe-fed streams, proxied
responses.
---
## Custom headers
```java
res.header("X-Request-Id", "abc123")
.header("Cache-Control", "no-store")
```
Each header is encoded as `"Name: Value\r\n"` bytes **at call time** and stored in a list.
On the write path the pre-encoded arrays are written directly — zero allocation per request.
---
## Fluent chaining
All mutating methods return `this`. A complete response in one expression:
```java
return res.status(HttpStatus.CREATED)
.type(ContentType.JSON)
.header("X-Trace-Id", traceId)
.body(json);
```
---
## Body mode decision guide
| Payload | Mode | Header emitted |
|----------------------------------|-------------------------------|-------------------------|
| Small / fully known | `body()` | `Content-Length` |
| File or stream with known size | `stream(is, length)` | `Content-Length` |
| Generated / piped / unknown size | `chunked(is)` | `Transfer-Encoding: chunked` |
---
## Memory model
| Call | Allocation |
|-----------------------------|-------------------------------------------------|
| `status(HttpStatus)` | none — writes pre-encoded `byte[]` from enum |
| `status(int)` | none — O(1) array lookup at write time |
| `type(ContentType)` | none — pre-encoded `byte[]` from enum |
| `type(String)` | encodes once at call time, stored as `byte[]` |
| `body(String)` | one UTF-8 encode at call time |
| `body(byte[])` | none — array stored by reference |
| `stream(is, len)` | none — stream drained directly to socket |
| `chunked(is)` | 8 KB read buffer per write call (stack-local) |
| `header(name, value)` | one UTF-8 encode at call time per header |