multipart parsing, request body access, and chunked input stream support
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:api.github.com)",
|
||||||
|
"Bash(xargs grep:*)",
|
||||||
|
"Bash(jfr summary:*)",
|
||||||
|
"Bash(jfr print:*)",
|
||||||
|
"Bash(sort -t= -k4 -rn)",
|
||||||
|
"Bash(sed 's/.*objectClass = //')",
|
||||||
|
"Bash(sed 's/ \\(.*//')",
|
||||||
|
"Bash(sed 's/^\\\\s*//')",
|
||||||
|
"Bash(jar tf:*)",
|
||||||
|
"Bash(javap -c -p /c/Users/elorc/.m2/repository/dev/relism/fpr-core/1.1.0/fpr-core-1.1.0.jar!/dev/relism/fpr/core/internal/runtime/FrozenRouter.class)",
|
||||||
|
"Read(//c/tmp/**)",
|
||||||
|
"Bash(jar xf:*)",
|
||||||
|
"Bash(javap -c -p dev/relism/fpr/core/internal/runtime/FrozenRouter.class)",
|
||||||
|
"Bash(javap -c -p dev/relism/fpr/core/internal/runtime/RouteSearch.class)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()` |
|
||||||
@@ -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);
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<parent>
|
||||||
|
<artifactId>flash-parent</artifactId>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<artifactId>flash-bench</artifactId>
|
||||||
|
<build>
|
||||||
|
<finalName>flash-bench</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<transformers>
|
||||||
|
<transformer>
|
||||||
|
<mainClass>dev.relism.bench.Main</mainClass>
|
||||||
|
</transformer>
|
||||||
|
<transformer />
|
||||||
|
</transformers>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>*:*</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/*.SF</exclude>
|
||||||
|
<exclude>META-INF/*.DSA</exclude>
|
||||||
|
<exclude>META-INF/*.RSA</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.44</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<ahc.version>2.12.3</ahc.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package dev.relism.bench;
|
||||||
|
|
||||||
|
import dev.relism.HttpServer;
|
||||||
|
import dev.relism.HttpServerConfiguration;
|
||||||
|
import dev.relism.http.ContentType;
|
||||||
|
import org.asynchttpclient.AsyncHttpClient;
|
||||||
|
import org.asynchttpclient.DefaultAsyncHttpClientConfig;
|
||||||
|
import org.asynchttpclient.Dsl;
|
||||||
|
import org.asynchttpclient.Request;
|
||||||
|
import org.asynchttpclient.RequestBuilder;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class Main {
|
||||||
|
|
||||||
|
// Lazy holder — Netty starts only on the first AHC request, not at class load.
|
||||||
|
// Routes like /plaintext that never call AHC are unaffected.
|
||||||
|
private static final class AhcHolder {
|
||||||
|
static final AsyncHttpClient AHC = Dsl.asyncHttpClient(
|
||||||
|
new DefaultAsyncHttpClientConfig.Builder()
|
||||||
|
.setMaxConnections(256)
|
||||||
|
.setMaxConnectionsPerHost(256)
|
||||||
|
.setKeepAlive(true)
|
||||||
|
.setPooledConnectionIdleTimeout(60_000)
|
||||||
|
.setConnectTimeout(5_000)
|
||||||
|
.setRequestTimeout(10_000)
|
||||||
|
.setIoThreadsCount(1)
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-built request objects — URI parsing done once at startup.
|
||||||
|
private static final Request ELEMENT_REQ = new RequestBuilder("GET")
|
||||||
|
.setUrl("http://web-data-source/element.json").build();
|
||||||
|
private static final Request SHELLS_REQ = new RequestBuilder("GET")
|
||||||
|
.setUrl("http://web-data-source/shells.json").build();
|
||||||
|
|
||||||
|
private static final byte[] PREFIX_SHELLS = "{\"shells\":".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final byte[] SUFFIX = "}".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
int port = args.length > 0 ? Integer.parseInt(args[0]) : 3000;
|
||||||
|
|
||||||
|
HttpServer server = new HttpServer(
|
||||||
|
HttpServerConfiguration.builder()
|
||||||
|
.port(port).host("0.0.0.0").build()
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/api/v1/periodic-table/element", (req, res) ->
|
||||||
|
fetchAndFilter(req.query("symbol"), false));
|
||||||
|
server.get("/api/v1/periodic-table/shells", (req, res) ->
|
||||||
|
fetchAndFilter(req.query("symbol"), true));
|
||||||
|
server.get("/plaintext", (req, res) -> "Hello, World!");
|
||||||
|
server.get("/json", (req, res) -> {
|
||||||
|
res.setContentType(ContentType.JSON);
|
||||||
|
return "{\"message\":\"Hello, World!\"}";
|
||||||
|
});
|
||||||
|
|
||||||
|
Path frontendDist = Path.of("C:\\Users\\elorc\\Documents\\Coding\\web\\PixelDocs\\.vitepress\\dist");
|
||||||
|
StaticResourceManager cdn = new StaticResourceManager(frontendDist, "/");
|
||||||
|
|
||||||
|
cdn.register(server);
|
||||||
|
|
||||||
|
server.start().thenRun(() -> System.out.println("Flash Sharkbench online on port " + port));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static dev.relism.models.Response fetchAndFilter(String symbol, boolean wrapShells) {
|
||||||
|
if (symbol == null) return new dev.relism.models.Response(400, "Missing symbol", ContentType.TEXT_PLAIN);
|
||||||
|
try {
|
||||||
|
// executeRequest() returns a ListenableFuture; .get() parks the calling
|
||||||
|
// virtual thread (cheap) while Netty's I/O thread handles the socket.
|
||||||
|
byte[] body = AhcHolder.AHC.executeRequest(wrapShells ? SHELLS_REQ : ELEMENT_REQ)
|
||||||
|
.get()
|
||||||
|
.getResponseBodyAsBytes();
|
||||||
|
|
||||||
|
byte[] value = extractJsonValue(body, symbol);
|
||||||
|
if (value == null) return new dev.relism.models.Response(404, "Not Found", ContentType.TEXT_PLAIN);
|
||||||
|
|
||||||
|
if (wrapShells) {
|
||||||
|
byte[] wrapped = new byte[PREFIX_SHELLS.length + value.length + SUFFIX.length];
|
||||||
|
System.arraycopy(PREFIX_SHELLS, 0, wrapped, 0, PREFIX_SHELLS.length);
|
||||||
|
System.arraycopy(value, 0, wrapped, PREFIX_SHELLS.length, value.length);
|
||||||
|
System.arraycopy(SUFFIX, 0, wrapped, PREFIX_SHELLS.length + value.length, SUFFIX.length);
|
||||||
|
return new dev.relism.models.Response(200, wrapped, ContentType.JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new dev.relism.models.Response(200, value, ContentType.JSON);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new dev.relism.models.Response(500, "Internal Error", ContentType.TEXT_PLAIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the JSON value for a given key from a flat JSON object.
|
||||||
|
* Zero Jackson allocations — pure byte scan.
|
||||||
|
*/
|
||||||
|
private static byte[] extractJsonValue(byte[] json, String key) {
|
||||||
|
byte[] keyBytes = ("\"" + key + "\":").getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
int pos = indexOf(json, keyBytes);
|
||||||
|
if (pos == -1) return null;
|
||||||
|
pos += keyBytes.length;
|
||||||
|
|
||||||
|
while (pos < json.length && json[pos] == ' ') pos++;
|
||||||
|
if (pos >= json.length) return null;
|
||||||
|
|
||||||
|
byte opener = json[pos];
|
||||||
|
byte closer;
|
||||||
|
if (opener == '{') closer = '}';
|
||||||
|
else if (opener == '[') closer = ']';
|
||||||
|
else return null;
|
||||||
|
|
||||||
|
int depth = 0;
|
||||||
|
boolean inString = false;
|
||||||
|
int start = pos;
|
||||||
|
|
||||||
|
while (pos < json.length) {
|
||||||
|
byte b = json[pos];
|
||||||
|
if (b == '"' && !isEscaped(json, pos)) inString = !inString;
|
||||||
|
if (!inString) {
|
||||||
|
if (b == opener) depth++;
|
||||||
|
else if (b == closer) { if (--depth == 0) { pos++; break; } }
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arrays.copyOfRange(json, start, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] haystack, byte[] needle) {
|
||||||
|
outer:
|
||||||
|
for (int i = 0; i <= haystack.length - needle.length; i++) {
|
||||||
|
for (int j = 0; j < needle.length; j++) {
|
||||||
|
if (haystack[i + j] != needle[j]) continue outer;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEscaped(byte[] data, int pos) {
|
||||||
|
int backslashes = 0;
|
||||||
|
while (--pos >= 0 && data[pos] == '\\') backslashes++;
|
||||||
|
return (backslashes & 1) == 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"H": {"name": "Hydrogen", "number": 1, "group": 1},
|
||||||
|
"He": {"name": "Helium", "number": 2, "group": 18},
|
||||||
|
"Li": {"name": "Lithium", "number": 3, "group": 1},
|
||||||
|
"Be": {"name": "Beryllium", "number": 4, "group": 2},
|
||||||
|
"B": {"name": "Boron", "number": 5, "group": 13},
|
||||||
|
"C": {"name": "Carbon", "number": 6, "group": 14},
|
||||||
|
"N": {"name": "Nitrogen", "number": 7, "group": 15},
|
||||||
|
"O": {"name": "Oxygen", "number": 8, "group": 16},
|
||||||
|
"F": {"name": "Fluorine", "number": 9, "group": 17},
|
||||||
|
"Ne": {"name": "Neon", "number": 10, "group": 18}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"H": {"shells": [1]},
|
||||||
|
"He": {"shells": [2]},
|
||||||
|
"Li": {"shells": [2, 1]},
|
||||||
|
"Be": {"shells": [2, 2]},
|
||||||
|
"B": {"shells": [2, 3]},
|
||||||
|
"C": {"shells": [2, 4]},
|
||||||
|
"N": {"shells": [2, 5]},
|
||||||
|
"O": {"shells": [2, 6]},
|
||||||
|
"F": {"shells": [2, 7]},
|
||||||
|
"Ne": {"shells": [2, 8]}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package dev.relism;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.SequenceInputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-chunking {@link InputStream} for HTTP/1.1 {@code Transfer-Encoding: chunked} request bodies.
|
||||||
|
* Handles pre-buffered bytes from the header read-ahead, chunk framing, and trailer consumption.
|
||||||
|
* Returns -1 at end of the final chunk; the underlying socket is left positioned for the next request.
|
||||||
|
*/
|
||||||
|
final class ChunkedInputStream extends InputStream {
|
||||||
|
private final InputStream src;
|
||||||
|
private int chunkRemaining = 0;
|
||||||
|
private boolean done = false;
|
||||||
|
|
||||||
|
ChunkedInputStream(InputStream socket, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||||
|
src = preBufLen > 0
|
||||||
|
? new SequenceInputStream(new ByteArrayInputStream(preBuf, preBufOff, preBufLen), socket)
|
||||||
|
: socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
if (done) return -1;
|
||||||
|
while (chunkRemaining == 0) {
|
||||||
|
chunkRemaining = readChunkSize();
|
||||||
|
if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; }
|
||||||
|
}
|
||||||
|
int b = src.read();
|
||||||
|
if (b >= 0 && --chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] buf, int off, int len) throws IOException {
|
||||||
|
if (done) return -1;
|
||||||
|
while (chunkRemaining == 0) {
|
||||||
|
chunkRemaining = readChunkSize();
|
||||||
|
if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; }
|
||||||
|
}
|
||||||
|
int n = src.read(buf, off, Math.min(len, chunkRemaining));
|
||||||
|
if (n > 0) {
|
||||||
|
chunkRemaining -= n;
|
||||||
|
if (chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readChunkSize() throws IOException {
|
||||||
|
int size = 0, b;
|
||||||
|
while ((b = src.read()) != -1) {
|
||||||
|
if (b >= '0' && b <= '9') size = size * 16 + (b - '0');
|
||||||
|
else if (b >= 'a' && b <= 'f') size = size * 16 + (b - 'a' + 10);
|
||||||
|
else if (b >= 'A' && b <= 'F') size = size * 16 + (b - 'A' + 10);
|
||||||
|
else { // ';' (extensions) or '\r' — skip to end of line
|
||||||
|
while ((b = src.read()) != -1 && b != '\n');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads and discards trailer headers until the empty line that terminates the chunked body.
|
||||||
|
private void consumeTrailers() throws IOException {
|
||||||
|
while (true) {
|
||||||
|
int b = src.read();
|
||||||
|
if (b == -1 || b == '\r') { src.read(); return; } // empty line — done
|
||||||
|
while ((b = src.read()) != -1 && b != '\n'); // skip non-empty trailer line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package dev.relism.api.multipart;
|
||||||
|
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy streaming {@code multipart/form-data} parser.
|
||||||
|
*
|
||||||
|
* <p>Reads from {@code body.stream()} — the request body is <em>never</em> fully materialised.
|
||||||
|
* Text fields are buffered eagerly on first encounter (they are small by definition). File part
|
||||||
|
* bodies are exposed as zero-copy {@link InputStream}s backed directly by the socket stream and
|
||||||
|
* must be consumed before the next call to any scan method.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* Multipart mp = Multipart.of(req);
|
||||||
|
*
|
||||||
|
* // Text fields — buffered eagerly, accessible in any call order
|
||||||
|
* String userId = mp.field("userId");
|
||||||
|
* String width = mp.field("width");
|
||||||
|
*
|
||||||
|
* // File parts — zero-copy socket stream; consume before requesting the next file
|
||||||
|
* Part avatar = mp.file("avatar");
|
||||||
|
* Files.copy(avatar.stream(), destination);
|
||||||
|
*
|
||||||
|
* // Explicit materialization of a file body (opt-in heap allocation)
|
||||||
|
* byte[] data = mp.file("doc").materialize();
|
||||||
|
*
|
||||||
|
* // Collect everything at once — eagerly buffers all bodies (accept the memory cost)
|
||||||
|
* List<Part> files = mp.parts("files");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p><b>Scan ordering:</b> {@link #field} and {@link #file} scan forward through the stream.
|
||||||
|
* Parts already passed cannot be re-read. Text fields encountered while scanning toward a file
|
||||||
|
* are buffered silently; file bodies encountered while scanning toward a text field are drained
|
||||||
|
* silently. Use {@link #parts()} only if you need everything and accept full materialisation.
|
||||||
|
*
|
||||||
|
* <p><b>Thread safety:</b> not thread-safe; one instance per request.
|
||||||
|
*/
|
||||||
|
public final class Multipart {
|
||||||
|
|
||||||
|
private static final int BUF_CAP = 8192;
|
||||||
|
|
||||||
|
private final InputStream src;
|
||||||
|
private final byte[] crlfBound; // "\r\n--<boundary>"
|
||||||
|
|
||||||
|
private final byte[] win;
|
||||||
|
private int wPos = 0;
|
||||||
|
private int wLen = 0;
|
||||||
|
private boolean srcEof = false;
|
||||||
|
private boolean done = false;
|
||||||
|
|
||||||
|
private final List<Part> scanned = new ArrayList<>();
|
||||||
|
private PartBodyStream active = null; // open file stream; must be drained before next scan
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Multipart(InputStream src, String boundary) throws IOException {
|
||||||
|
this.src = src;
|
||||||
|
this.crlfBound = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
this.win = new byte[BUF_CAP + crlfBound.length];
|
||||||
|
skipFromWindow(2 + boundary.length() + 2); // "--boundary\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a parser for {@code request}. Uses {@code body.stream()} — zero heap allocation.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the request is not multipart or boundary is missing
|
||||||
|
* @throws IOException if the initial stream read fails
|
||||||
|
*/
|
||||||
|
public static Multipart of(Request req) throws IOException {
|
||||||
|
String ct = req.header("Content-Type");
|
||||||
|
if (ct == null || !ct.startsWith("multipart/"))
|
||||||
|
throw new IllegalArgumentException("Not a multipart request (Content-Type: " + ct + ")");
|
||||||
|
String boundary = extractParam(ct, "boundary");
|
||||||
|
if (boundary == null)
|
||||||
|
throw new IllegalArgumentException("Missing boundary in Content-Type: " + ct);
|
||||||
|
return new Multipart(req.body().stream(), boundary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the text value of the first field named {@code name}, or {@code null}.
|
||||||
|
* Scans forward; text parts encountered along the way are buffered, file bodies are drained.
|
||||||
|
*/
|
||||||
|
public String field(String name) throws IOException {
|
||||||
|
for (Part p : scanned)
|
||||||
|
if (name.equals(p.name()) && !p.isFile()) return p.text();
|
||||||
|
while (!done) {
|
||||||
|
Part p = scanNext(false);
|
||||||
|
if (p != null && name.equals(p.name()) && !p.isFile()) return p.text();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first file part named {@code name}, or {@code null}.
|
||||||
|
* Scans forward; text parts encountered along the way are buffered, earlier file bodies
|
||||||
|
* are drained. The returned part's stream must be consumed before the next scan call.
|
||||||
|
*/
|
||||||
|
public Part file(String name) throws IOException {
|
||||||
|
for (Part p : scanned)
|
||||||
|
if (name.equals(p.name()) && p.isFile()) return p; // already materialized via parts()
|
||||||
|
while (!done) {
|
||||||
|
Part p = scanNext(false);
|
||||||
|
if (p != null && name.equals(p.name()) && p.isFile()) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all parts named {@code name} in declaration order.
|
||||||
|
* Forces a full scan; all file bodies are materialised into heap.
|
||||||
|
*/
|
||||||
|
public List<Part> parts(String name) throws IOException {
|
||||||
|
scanAll();
|
||||||
|
List<Part> result = new ArrayList<>();
|
||||||
|
for (Part p : scanned) if (name.equals(p.name())) result.add(p);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all parts in declaration order.
|
||||||
|
* Forces a full scan; all file bodies are materialised into heap.
|
||||||
|
*/
|
||||||
|
public List<Part> parts() throws IOException {
|
||||||
|
scanAll();
|
||||||
|
return List.copyOf(scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scan
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void scanAll() throws IOException { while (!done) scanNext(true); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the next part.
|
||||||
|
*
|
||||||
|
* @param materialize if {@code true}, file part bodies are buffered immediately
|
||||||
|
* (used by {@link #parts()} and {@link #parts(String)})
|
||||||
|
*/
|
||||||
|
private Part scanNext(boolean materialize) throws IOException {
|
||||||
|
if (done) return null;
|
||||||
|
drainActive();
|
||||||
|
if (done) return null;
|
||||||
|
|
||||||
|
Map<String, String> headers = readPartHeaders();
|
||||||
|
if (headers == null) { done = true; return null; }
|
||||||
|
|
||||||
|
String disp = headers.get("content-disposition");
|
||||||
|
String name = extractParam(disp, "name");
|
||||||
|
String filename = extractParam(disp, "filename");
|
||||||
|
String ct = headers.get("content-type");
|
||||||
|
|
||||||
|
active = new PartBodyStream();
|
||||||
|
|
||||||
|
Part p;
|
||||||
|
if (filename != null && !materialize) {
|
||||||
|
// File part — expose streaming body; not cached (stream is consumed once)
|
||||||
|
p = Part.streaming(name, filename, ct, active);
|
||||||
|
} else {
|
||||||
|
// Text part, or full-scan path: buffer body now
|
||||||
|
byte[] body = active.readAllBytes();
|
||||||
|
active = null;
|
||||||
|
p = Part.buffered(name, filename, ct, body);
|
||||||
|
scanned.add(p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PartBodyStream — inner class sharing the window buffer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
final class PartBodyStream extends InputStream {
|
||||||
|
boolean bodyEof = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] buf, int off, int len) throws IOException {
|
||||||
|
if (bodyEof) return -1;
|
||||||
|
refill();
|
||||||
|
if (wLen == 0) { seal(); return -1; }
|
||||||
|
|
||||||
|
int delimAt = findDelim(wPos, wLen);
|
||||||
|
int available;
|
||||||
|
if (delimAt >= 0) {
|
||||||
|
available = delimAt - wPos;
|
||||||
|
if (available == 0) { advancePastBoundary(); seal(); return -1; }
|
||||||
|
} else {
|
||||||
|
available = srcEof ? wLen : Math.max(0, wLen - (crlfBound.length - 1));
|
||||||
|
if (available == 0) { refill(); return read(buf, off, len); }
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = Math.min(len, available);
|
||||||
|
System.arraycopy(win, wPos, buf, off, n);
|
||||||
|
wPos += n;
|
||||||
|
wLen -= n;
|
||||||
|
if (delimAt >= 0 && wPos == delimAt) { advancePastBoundary(); seal(); }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
byte[] b = {0};
|
||||||
|
return read(b, 0, 1) < 0 ? -1 : b[0] & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks this stream as exhausted and releases the active slot. */
|
||||||
|
private void seal() { bodyEof = true; if (active == this) active = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Window management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void refill() throws IOException {
|
||||||
|
if (srcEof) return;
|
||||||
|
if (wPos > 0) { System.arraycopy(win, wPos, win, 0, wLen); wPos = 0; }
|
||||||
|
int space = win.length - wLen;
|
||||||
|
if (space > 0) {
|
||||||
|
int n = src.read(win, wLen, space);
|
||||||
|
if (n < 0) srcEof = true; else wLen += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void skipFromWindow(int bytes) throws IOException {
|
||||||
|
int rem = bytes;
|
||||||
|
while (rem > 0) { refill(); int s = Math.min(rem, wLen); wPos += s; wLen -= s; rem -= s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains the active file stream so we can advance to the next part. */
|
||||||
|
private void drainActive() throws IOException {
|
||||||
|
if (active == null || active.bodyEof) return;
|
||||||
|
PartBodyStream ps = active;
|
||||||
|
byte[] sink = new byte[BUF_CAP];
|
||||||
|
while (ps.read(sink) >= 0) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void advancePastBoundary() throws IOException {
|
||||||
|
wPos += crlfBound.length;
|
||||||
|
wLen -= crlfBound.length;
|
||||||
|
refill();
|
||||||
|
if (wLen >= 2) {
|
||||||
|
if (win[wPos] == '-' && win[wPos + 1] == '-') {
|
||||||
|
done = true;
|
||||||
|
wPos += 2; wLen -= 2;
|
||||||
|
if (wLen >= 2) { wPos += 2; wLen -= 2; } // optional trailing \r\n
|
||||||
|
} else {
|
||||||
|
wPos += 2; wLen -= 2; // \r\n before next part's headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Header parsing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Map<String, String> readPartHeaders() throws IOException {
|
||||||
|
Map<String, String> map = new HashMap<>();
|
||||||
|
while (true) {
|
||||||
|
String line = readLine();
|
||||||
|
if (line == null || line.isEmpty()) break;
|
||||||
|
int colon = line.indexOf(':');
|
||||||
|
if (colon > 0)
|
||||||
|
map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
|
||||||
|
line.substring(colon + 1).trim());
|
||||||
|
}
|
||||||
|
return map.isEmpty() ? null : map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readLine() throws IOException {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
while (true) {
|
||||||
|
refill();
|
||||||
|
if (wLen == 0) return sb.length() > 0 ? sb.toString() : null;
|
||||||
|
|
||||||
|
int end = wPos + wLen;
|
||||||
|
for (int i = wPos; i < end - 1; i++) {
|
||||||
|
if (win[i] == '\r' && win[i + 1] == '\n') {
|
||||||
|
sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8));
|
||||||
|
int consumed = i - wPos + 2;
|
||||||
|
wPos += consumed; wLen -= consumed;
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No \r\n found yet — keep the last byte (might be a split \r\n) and refill
|
||||||
|
int append = srcEof ? wLen : (wLen > 0 ? wLen - 1 : 0);
|
||||||
|
if (append > 0) {
|
||||||
|
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
|
||||||
|
wPos += append; wLen -= append;
|
||||||
|
}
|
||||||
|
if (srcEof && wLen > 0) {
|
||||||
|
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
|
||||||
|
wPos += wLen; wLen = 0;
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Utilities
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private int findDelim(int from, int searchLen) {
|
||||||
|
int last = from + searchLen - crlfBound.length;
|
||||||
|
outer:
|
||||||
|
for (int i = from; i <= last; i++) {
|
||||||
|
for (int j = 0; j < crlfBound.length; j++)
|
||||||
|
if (win[i + j] != crlfBound[j]) continue outer;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractParam(String header, String param) {
|
||||||
|
if (header == null) return null;
|
||||||
|
int idx = header.indexOf(param + "=");
|
||||||
|
if (idx < 0) return null;
|
||||||
|
idx += param.length() + 1;
|
||||||
|
if (idx < header.length() && header.charAt(idx) == '"') {
|
||||||
|
int end = header.indexOf('"', idx + 1);
|
||||||
|
return end >= 0 ? header.substring(idx + 1, end) : null;
|
||||||
|
}
|
||||||
|
int end = header.indexOf(';', idx);
|
||||||
|
return end >= 0 ? header.substring(idx, end).trim() : header.substring(idx).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package dev.relism.api.multipart;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One part of a {@code multipart/form-data} body produced by {@link Multipart}.
|
||||||
|
*
|
||||||
|
* <p>Two internal states:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Buffered</b> (text fields and {@link Multipart#parts()} results) — body is in a
|
||||||
|
* {@code byte[]}; {@link #stream()}, {@link #materialize()}, and {@link #text()} are all
|
||||||
|
* repeatable and free of I/O.</li>
|
||||||
|
* <li><b>Streaming</b> (file parts returned by {@link Multipart#file(String)}) — body is a
|
||||||
|
* bounded socket {@link InputStream}; call {@link #stream()} or {@link #materialize()} exactly
|
||||||
|
* once, before requesting the next part from {@link Multipart}.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class Part {
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
private final String filename; // null → text field
|
||||||
|
private final String contentType;
|
||||||
|
private byte[] buf; // non-null once materialised
|
||||||
|
private final InputStream socketStream; // non-null only for streaming file parts
|
||||||
|
|
||||||
|
static Part buffered(String name, String filename, String contentType, byte[] buf) {
|
||||||
|
return new Part(name, filename, contentType, buf, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Part streaming(String name, String filename, String contentType, InputStream stream) {
|
||||||
|
return new Part(name, filename, contentType, null, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Part(String name, String filename, String contentType,
|
||||||
|
byte[] buf, InputStream socketStream) {
|
||||||
|
this.name = name;
|
||||||
|
this.filename = filename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
this.buf = buf;
|
||||||
|
this.socketStream = socketStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Metadata
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Field or file name from {@code Content-Disposition: form-data; name="..."}. */
|
||||||
|
public String name() { return name; }
|
||||||
|
|
||||||
|
/** Original filename from {@code filename="..."}, or {@code null} for text fields. */
|
||||||
|
public String filename() { return filename; }
|
||||||
|
|
||||||
|
/** {@code Content-Type} declared in the part headers, or {@code null} if absent. */
|
||||||
|
public String contentType() { return contentType; }
|
||||||
|
|
||||||
|
/** {@code true} if this part has a {@code filename} attribute (i.e. a file upload). */
|
||||||
|
public boolean isFile() { return filename != null; }
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Body access
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an {@link InputStream} over the part body.
|
||||||
|
* For buffered parts: returns a fresh reader each call.
|
||||||
|
* For streaming file parts: returns the raw socket stream — read once only.
|
||||||
|
*/
|
||||||
|
public InputStream stream() {
|
||||||
|
return buf != null ? new ByteArrayInputStream(buf) : socketStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialises the body into a {@code byte[]}. Result is cached; safe to call repeatedly.
|
||||||
|
* For streaming file parts: triggers a full read from the socket on first call.
|
||||||
|
*/
|
||||||
|
public byte[] materialize() throws IOException {
|
||||||
|
if (buf != null) return buf;
|
||||||
|
return buf = socketStream.readAllBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the body as UTF-8. Materialises if needed; result is cached.
|
||||||
|
* For streaming file parts: triggers a full read from the socket on first call.
|
||||||
|
*/
|
||||||
|
public String text() throws IOException {
|
||||||
|
return new String(materialize(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package dev.relism.models;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessor for the HTTP request body. Supports two mutually exclusive read modes per request:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #bytes()} — materialises the full body into a {@code byte[]} and caches it.
|
||||||
|
* Safe to call multiple times; the second call returns the cached array. Throws for
|
||||||
|
* bodies larger than 2 GB.</li>
|
||||||
|
* <li>{@link #stream()} — returns a bounded {@link InputStream} without upfront allocation.
|
||||||
|
* For fixed-length bodies this is a view into the already-buffered header bytes stitched
|
||||||
|
* to the socket; for chunked bodies it is the raw {@link dev.relism.ChunkedInputStream}
|
||||||
|
* that de-chunks on the fly.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
|
||||||
|
* request produces undefined results. Choose one mode per handler.
|
||||||
|
*
|
||||||
|
* <p><b>Keep-alive:</b> unread body bytes are discarded by {@link Request#drain()} after the
|
||||||
|
* handler returns so the socket is correctly positioned for the next pipelined request.
|
||||||
|
*/
|
||||||
|
public final class RequestBody {
|
||||||
|
private static final byte[] EMPTY_BYTES = new byte[0];
|
||||||
|
|
||||||
|
private final InputStream socket;
|
||||||
|
private final long contentLength;
|
||||||
|
private final byte[] preBuf;
|
||||||
|
private final int preBufOff;
|
||||||
|
private final int preBufLen;
|
||||||
|
|
||||||
|
private byte[] resolved;
|
||||||
|
private long socketConsumed;
|
||||||
|
|
||||||
|
RequestBody(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.contentLength = contentLength;
|
||||||
|
this.preBuf = preBuf;
|
||||||
|
this.preBufOff = preBufOff;
|
||||||
|
this.preBufLen = preBufLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static RequestBody of(byte[] bytes) {
|
||||||
|
RequestBody b = new RequestBody(null, bytes.length, null, 0, 0);
|
||||||
|
b.resolved = bytes;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final RequestBody EMPTY_INSTANCE;
|
||||||
|
static {
|
||||||
|
EMPTY_INSTANCE = new RequestBody(null, 0, null, 0, 0);
|
||||||
|
EMPTY_INSTANCE.resolved = EMPTY_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
static RequestBody empty() { return EMPTY_INSTANCE; }
|
||||||
|
|
||||||
|
/** {@code true} if the body has zero bytes ({@code Content-Length: 0} or no body). */
|
||||||
|
public boolean isEmpty() { return contentLength == 0; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declared body size in bytes. Returns {@code -1} for {@code Transfer-Encoding: chunked}
|
||||||
|
* bodies where the size is not known upfront.
|
||||||
|
*/
|
||||||
|
public long contentLength() { return contentLength; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialises and caches the full body. Suitable for JSON, small form data, and any payload
|
||||||
|
* that must be inspected in full. The result is cached — repeated calls return the same array.
|
||||||
|
*
|
||||||
|
* <p>For chunked bodies ({@link #contentLength()} {@code == -1}), reads until the chunked
|
||||||
|
* stream signals EOF.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException if {@link #contentLength()} exceeds {@code Integer.MAX_VALUE}
|
||||||
|
* (~2 GB); use {@link #stream()} for large bodies instead
|
||||||
|
*/
|
||||||
|
public byte[] bytes() {
|
||||||
|
if (resolved != null) return resolved;
|
||||||
|
if (contentLength < 0) { // chunked — read until ChunkedInputStream signals EOF
|
||||||
|
try { return resolved = socket.readAllBytes(); } catch (IOException e) { throw new UncheckedIOException(e); }
|
||||||
|
}
|
||||||
|
if (contentLength > Integer.MAX_VALUE)
|
||||||
|
throw new IllegalStateException("Body too large to materialize (" + contentLength + " bytes), use stream()");
|
||||||
|
int size = (int) contentLength;
|
||||||
|
int copied = (int) Math.min(preBufLen, contentLength);
|
||||||
|
byte[] buf = new byte[size];
|
||||||
|
if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied);
|
||||||
|
if (copied < size) {
|
||||||
|
try {
|
||||||
|
socket.readNBytes(buf, copied, size - copied);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
socketConsumed = contentLength - preBufLen;
|
||||||
|
}
|
||||||
|
return resolved = buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a bounded {@link InputStream} over the body without upfront allocation.
|
||||||
|
*
|
||||||
|
* <p>For fixed-length bodies: a {@link SequenceInputStream} of any already-buffered header
|
||||||
|
* bytes followed by a bounded view of the socket stream — zero heap beyond those small
|
||||||
|
* pre-buffered bytes.
|
||||||
|
*
|
||||||
|
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
|
||||||
|
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
|
||||||
|
* the next keep-alive request.
|
||||||
|
*
|
||||||
|
* <p>If {@link #bytes()} was called first, returns a fresh {@link java.io.ByteArrayInputStream}
|
||||||
|
* over the cached array.
|
||||||
|
*/
|
||||||
|
public InputStream stream() {
|
||||||
|
if (resolved != null) return new ByteArrayInputStream(resolved);
|
||||||
|
if (contentLength < 0) return socket; // ChunkedInputStream — EOF signals end of body
|
||||||
|
int fromBuf = (int) Math.min(preBufLen, contentLength);
|
||||||
|
long fromSocket = contentLength - fromBuf;
|
||||||
|
InputStream bufPart = new ByteArrayInputStream(preBuf, preBufOff, fromBuf);
|
||||||
|
return fromSocket == 0 ? bufPart : new SequenceInputStream(bufPart, bounded(socket, fromSocket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discards unread body bytes to reposition the socket for the next keep-alive request. */
|
||||||
|
void drain() {
|
||||||
|
if (isEmpty() || resolved != null) return;
|
||||||
|
if (contentLength < 0) {
|
||||||
|
try { socket.transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long remaining = (contentLength - preBufLen) - socketConsumed;
|
||||||
|
if (remaining > 0) try { socket.skipNBytes(remaining); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private InputStream bounded(InputStream src, long limit) {
|
||||||
|
return new InputStream() {
|
||||||
|
private long left = limit;
|
||||||
|
|
||||||
|
@Override public int read() throws IOException {
|
||||||
|
if (left == 0) return -1;
|
||||||
|
int b = src.read();
|
||||||
|
if (b >= 0) { left--; socketConsumed++; }
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int read(byte[] buf, int off, int len) throws IOException {
|
||||||
|
if (left == 0) return -1;
|
||||||
|
int n = src.read(buf, off, (int) Math.min(len, left));
|
||||||
|
if (n > 0) { left -= n; socketConsumed += n; }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package dev.relism;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ChunkedInputStreamTest {
|
||||||
|
|
||||||
|
private static ChunkedInputStream wrap(String chunkedEncoded) {
|
||||||
|
byte[] bytes = chunkedEncoded.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return new ChunkedInputStream(new ByteArrayInputStream(bytes), null, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readAll(ChunkedInputStream in) throws IOException {
|
||||||
|
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bulk reads ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleChunk() throws IOException {
|
||||||
|
assertEquals("hello", readAll(wrap("5\r\nhello\r\n0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleChunks() throws IOException {
|
||||||
|
assertEquals("hello world", readAll(wrap("5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyBody_terminatorOnly() throws IOException {
|
||||||
|
assertEquals("", readAll(wrap("0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hexDigitsUppercase() throws IOException {
|
||||||
|
// "A" = 10 bytes
|
||||||
|
String data = "0123456789";
|
||||||
|
assertEquals(data, readAll(wrap("A\r\n" + data + "\r\n0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hexDigitsLowercase() throws IOException {
|
||||||
|
// "a" = 10 bytes
|
||||||
|
String data = "0123456789";
|
||||||
|
assertEquals(data, readAll(wrap("a\r\n" + data + "\r\n0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkExtension_ignored() throws IOException {
|
||||||
|
// semicolon and extension are discarded, only size matters
|
||||||
|
assertEquals("hello", readAll(wrap("5;ext=val\r\nhello\r\n0\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trailers_consumed() throws IOException {
|
||||||
|
// trailing headers after 0-chunk must be consumed
|
||||||
|
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nTrailer: value\r\n\r\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- byte-by-byte read ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void byteByByteRead_singleChunk() throws IOException {
|
||||||
|
ChunkedInputStream in = wrap("3\r\nabc\r\n0\r\n\r\n");
|
||||||
|
assertEquals('a', in.read());
|
||||||
|
assertEquals('b', in.read());
|
||||||
|
assertEquals('c', in.read());
|
||||||
|
assertEquals(-1, in.read());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void byteByByteRead_multipleChunks() throws IOException {
|
||||||
|
ChunkedInputStream in = wrap("2\r\nhi\r\n2\r\n!!\r\n0\r\n\r\n");
|
||||||
|
assertEquals('h', in.read());
|
||||||
|
assertEquals('i', in.read());
|
||||||
|
assertEquals('!', in.read());
|
||||||
|
assertEquals('!', in.read());
|
||||||
|
assertEquals(-1, in.read());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EOF behaviour ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readAfterEof_returnsMinusOne() throws IOException {
|
||||||
|
ChunkedInputStream in = wrap("0\r\n\r\n");
|
||||||
|
assertEquals(-1, in.read());
|
||||||
|
assertEquals(-1, in.read()); // idempotent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readArrayAfterEof_returnsMinusOne() throws IOException {
|
||||||
|
ChunkedInputStream in = wrap("0\r\n\r\n");
|
||||||
|
assertEquals(-1, in.read(new byte[8], 0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pre-buffered data ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preBuf_prependedBeforeSocket() throws IOException {
|
||||||
|
// "5\r\nhello" in preBuf, "\r\n0\r\n\r\n" in socket
|
||||||
|
byte[] preBuf = "5\r\nhello".getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] socket = "\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||||
|
ChunkedInputStream in = new ChunkedInputStream(new ByteArrayInputStream(socket), preBuf, 0, preBuf.length);
|
||||||
|
assertEquals("hello", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preBuf_withOffset() throws IOException {
|
||||||
|
byte[] preBuf = "XX2\r\nhi\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||||
|
// offset=2, len=preBuf.length-2 — skip "XX"
|
||||||
|
ChunkedInputStream in = new ChunkedInputStream(
|
||||||
|
new ByteArrayInputStream(new byte[0]), preBuf, 2, preBuf.length - 2);
|
||||||
|
assertEquals("hi", new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package dev.relism.api.multipart;
|
||||||
|
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
import dev.relism.http.HttpMethod;
|
||||||
|
import dev.relism.models.HeaderMap;
|
||||||
|
import dev.relism.models.Request;
|
||||||
|
import dev.relism.models.RequestLine;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class MultipartTest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static final String BOUNDARY = "testboundary";
|
||||||
|
|
||||||
|
private static byte[] body(String... parts) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String part : parts)
|
||||||
|
sb.append("--").append(BOUNDARY).append("\r\n").append(part).append("\r\n");
|
||||||
|
sb.append("--").append(BOUNDARY).append("--\r\n");
|
||||||
|
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textPart(String name, String value) {
|
||||||
|
return "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String filePart(String name, String filename, String contentType, String value) {
|
||||||
|
return "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n"
|
||||||
|
+ "Content-Type: " + contentType + "\r\n\r\n" + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Request request(byte[] bodyBytes) {
|
||||||
|
String ct = "multipart/form-data; boundary=" + BOUNDARY;
|
||||||
|
byte[] headerBuf = ("Content-Type: " + ct).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
HeaderMap headers = new HeaderMap();
|
||||||
|
headers.reset(headerBuf, 0, headerBuf.length);
|
||||||
|
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/upload"), null, viewOf("HTTP/1.1"), headers);
|
||||||
|
return new Request(line, bodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ByteView viewOf(String s) {
|
||||||
|
byte[] b = s.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return new ByteView() {
|
||||||
|
public int length() { return b.length; }
|
||||||
|
public byte byteAt(int i) { return b[i]; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// field()
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_singleField_returnsValue() throws IOException {
|
||||||
|
assertEquals("alice", Multipart.of(request(body(textPart("username", "alice")))).field("username"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_absent_returnsNull() throws IOException {
|
||||||
|
assertNull(Multipart.of(request(body(textPart("username", "alice")))).field("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_filePart_returnsNull() throws IOException {
|
||||||
|
// file parts must not be returned by field()
|
||||||
|
assertNull(Multipart.of(request(body(filePart("photo", "img.png", "image/png", "data")))).field("photo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_multipleFields_anyOrder() throws IOException {
|
||||||
|
// Text fields are buffered eagerly — accessible in any call order regardless of declaration order
|
||||||
|
Multipart mp = Multipart.of(request(body(textPart("a", "1"), textPart("b", "2"), textPart("c", "3"))));
|
||||||
|
assertEquals("3", mp.field("c"));
|
||||||
|
assertEquals("1", mp.field("a")); // already in cache
|
||||||
|
assertEquals("2", mp.field("b")); // already in cache
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_accessibleAfterFilePart() throws IOException {
|
||||||
|
// "text" comes AFTER "file" — Multipart must drain the file body silently
|
||||||
|
Multipart mp = Multipart.of(request(body(
|
||||||
|
filePart("file", "f.bin", "application/octet-stream", "binary"),
|
||||||
|
textPart("text", "hello"))));
|
||||||
|
assertEquals("hello", mp.field("text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// file()
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void file_filePart_returnsPartWithMetadata() throws IOException {
|
||||||
|
Part p = Multipart.of(request(body(filePart("avatar", "me.jpg", "image/jpeg", "JFIF")))).file("avatar");
|
||||||
|
|
||||||
|
assertNotNull(p);
|
||||||
|
assertTrue(p.isFile());
|
||||||
|
assertEquals("avatar", p.name());
|
||||||
|
assertEquals("me.jpg", p.filename());
|
||||||
|
assertEquals("image/jpeg", p.contentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void file_absent_returnsNull() throws IOException {
|
||||||
|
assertNull(Multipart.of(request(body(textPart("x", "y")))).file("photo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void file_fieldPart_returnsNull() throws IOException {
|
||||||
|
// text fields must not be returned by file()
|
||||||
|
assertNull(Multipart.of(request(body(textPart("name", "bob")))).file("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void file_accessibleAfterTextField() throws IOException {
|
||||||
|
// "file" comes AFTER "userId" — text field is buffered while scanning toward file
|
||||||
|
Multipart mp = Multipart.of(request(body(
|
||||||
|
textPart("userId", "42"),
|
||||||
|
filePart("file", "doc.pdf", "application/pdf", "PDF"))));
|
||||||
|
|
||||||
|
Part file = mp.file("file");
|
||||||
|
assertNotNull(file);
|
||||||
|
assertEquals("doc.pdf", file.filename());
|
||||||
|
assertEquals("42", mp.field("userId")); // already cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Body access
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void part_text_returnsUtf8Value() throws IOException {
|
||||||
|
Part p = Multipart.of(request(body(textPart("note", "héllo")))).parts("note").getFirst();
|
||||||
|
assertEquals("héllo", p.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void part_bytes_returnsCorrectCopy() throws IOException {
|
||||||
|
byte[] expected = "binary\0data".getBytes(StandardCharsets.UTF_8);
|
||||||
|
Part p = Multipart.of(request(body(
|
||||||
|
filePart("f", "f.bin", "application/octet-stream",
|
||||||
|
new String(expected, StandardCharsets.UTF_8))))).parts("f").getFirst();
|
||||||
|
assertArrayEquals(expected, p.materialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void part_stream_hasCorrectContent() throws IOException {
|
||||||
|
Part p = Multipart.of(request(body(textPart("data", "streamed")))).parts("data").getFirst();
|
||||||
|
assertArrayEquals("streamed".getBytes(StandardCharsets.UTF_8), p.stream().readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void part_stream_isRepeatable_forBufferedParts() throws IOException {
|
||||||
|
// Buffered parts (text fields, parts() results) can be read multiple times
|
||||||
|
Part p = Multipart.of(request(body(textPart("k", "v")))).parts("k").getFirst();
|
||||||
|
assertNotSame(p.stream(), p.stream()); // fresh ByteArrayInputStream each call
|
||||||
|
assertArrayEquals("v".getBytes(StandardCharsets.UTF_8), p.stream().readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void part_bytes_isCached() throws IOException {
|
||||||
|
Part p = Multipart.of(request(body(textPart("k", "v")))).parts("k").getFirst();
|
||||||
|
assertSame(p.materialize(), p.materialize()); // second call returns cached array
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Multiple parts / parts()
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parts_sameNameMultiple_allReturned() throws IOException {
|
||||||
|
Multipart mp = Multipart.of(request(body(
|
||||||
|
textPart("tag", "alpha"),
|
||||||
|
textPart("tag", "beta"),
|
||||||
|
textPart("tag", "gamma"))));
|
||||||
|
|
||||||
|
List<Part> tags = mp.parts("tag");
|
||||||
|
assertEquals(3, tags.size());
|
||||||
|
assertEquals("alpha", tags.get(0).text());
|
||||||
|
assertEquals("beta", tags.get(1).text());
|
||||||
|
assertEquals("gamma", tags.get(2).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parts_allParts_inDeclarationOrder() throws IOException {
|
||||||
|
List<Part> all = Multipart.of(request(body(
|
||||||
|
textPart("first", "1"),
|
||||||
|
filePart("second", "s.bin", "application/octet-stream", "2"),
|
||||||
|
textPart("third", "3")))).parts();
|
||||||
|
|
||||||
|
assertEquals(3, all.size());
|
||||||
|
assertEquals("first", all.get(0).name());
|
||||||
|
assertEquals("second", all.get(1).name());
|
||||||
|
assertEquals("third", all.get(2).name());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Large-body simulation (streaming correctness)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void file_bodyLargerThanWindow_streamedCorrectly() throws IOException {
|
||||||
|
// Produce a body > 8 KB to exercise multi-refill window logic
|
||||||
|
String large = "x".repeat(20_000);
|
||||||
|
Part p = Multipart.of(request(body(
|
||||||
|
filePart("big", "big.txt", "text/plain", large)))).file("big");
|
||||||
|
|
||||||
|
assertNotNull(p);
|
||||||
|
byte[] got = p.materialize();
|
||||||
|
assertArrayEquals(large.getBytes(StandardCharsets.UTF_8), got);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void field_afterLargeFile_drained_thenAccessible() throws IOException {
|
||||||
|
// File part (>8 KB) before a text field — file must be drained, text must be accessible
|
||||||
|
String large = "y".repeat(20_000);
|
||||||
|
Multipart mp = Multipart.of(request(body(
|
||||||
|
filePart("file", "f.bin", "application/octet-stream", large),
|
||||||
|
textPart("meta", "value"))));
|
||||||
|
|
||||||
|
assertEquals("value", mp.field("meta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Error cases
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_notMultipart_throws() {
|
||||||
|
byte[] headerBuf = "Content-Type: application/json".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
HeaderMap headers = new HeaderMap();
|
||||||
|
headers.reset(headerBuf, 0, headerBuf.length);
|
||||||
|
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||||
|
Request req = new Request(line, new byte[0]);
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> Multipart.of(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package dev.relism.models;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RequestBodyTest {
|
||||||
|
|
||||||
|
// --- static factories ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void of_resolvedImmediately() {
|
||||||
|
byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = RequestBody.of(data);
|
||||||
|
assertArrayEquals(data, body.bytes());
|
||||||
|
assertFalse(body.isEmpty());
|
||||||
|
assertEquals(5, body.contentLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void empty_isEmptyAndZeroLength() {
|
||||||
|
RequestBody body = RequestBody.empty();
|
||||||
|
assertTrue(body.isEmpty());
|
||||||
|
assertEquals(0, body.contentLength());
|
||||||
|
assertEquals(0, body.bytes().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bytes() ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_fromPreBufOnly() {
|
||||||
|
byte[] preBuf = "world".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(null, 5, preBuf, 0, 5);
|
||||||
|
assertArrayEquals(preBuf, body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_fromPreBufWithOffset() {
|
||||||
|
byte[] preBuf = "xxhelloxx".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(null, 5, preBuf, 2, 5);
|
||||||
|
assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_fromSocketOnly() {
|
||||||
|
byte[] data = "socket".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(new ByteArrayInputStream(data), 6, new byte[0], 0, 0);
|
||||||
|
assertArrayEquals(data, body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_fromPreBufAndSocket() {
|
||||||
|
byte[] preBuf = "hel".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(
|
||||||
|
new ByteArrayInputStream("lo".getBytes(StandardCharsets.UTF_8)),
|
||||||
|
5, preBuf, 0, 3);
|
||||||
|
assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_cached_returnsSameReference() {
|
||||||
|
RequestBody body = RequestBody.of("cached".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertSame(body.bytes(), body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_chunked_readsAllFromSocket() {
|
||||||
|
// contentLength == -1 → bytes() calls socket.readAllBytes()
|
||||||
|
byte[] data = "chunked content".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(new ByteArrayInputStream(data), -1L, null, 0, 0);
|
||||||
|
assertArrayEquals(data, body.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bytes_tooLarge_throwsIllegalStateException() {
|
||||||
|
RequestBody body = new RequestBody(InputStream.nullInputStream(), (long) Integer.MAX_VALUE + 1, null, 0, 0);
|
||||||
|
assertThrows(IllegalStateException.class, body::bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stream() ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_onResolved_returnsBytesWrapped() throws IOException {
|
||||||
|
byte[] data = "stream".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = RequestBody.of(data);
|
||||||
|
assertArrayEquals(data, body.stream().readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_fromPreBufOnly() throws IOException {
|
||||||
|
byte[] preBuf = "buf".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(null, 3, preBuf, 0, 3);
|
||||||
|
assertArrayEquals(preBuf, body.stream().readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_fromPreBufAndSocket() throws IOException {
|
||||||
|
byte[] preBuf = "hel".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(
|
||||||
|
new ByteArrayInputStream("lo".getBytes(StandardCharsets.UTF_8)),
|
||||||
|
5, preBuf, 0, 3);
|
||||||
|
assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), body.stream().readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_chunked_returnsSocketDirectly() {
|
||||||
|
InputStream socket = InputStream.nullInputStream();
|
||||||
|
RequestBody body = new RequestBody(socket, -1L, null, 0, 0);
|
||||||
|
assertSame(socket, body.stream());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_afterBytes_returnsCachedBytes() throws IOException {
|
||||||
|
byte[] data = "data".getBytes(StandardCharsets.UTF_8);
|
||||||
|
RequestBody body = new RequestBody(new ByteArrayInputStream(data), 4, new byte[0], 0, 0);
|
||||||
|
body.bytes(); // resolves and caches
|
||||||
|
assertArrayEquals(data, body.stream().readAllBytes()); // wraps cached bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drain() ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_empty_noOp() {
|
||||||
|
assertDoesNotThrow(RequestBody.empty()::drain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_resolved_noOp() {
|
||||||
|
assertDoesNotThrow(RequestBody.of("data".getBytes())::drain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_skipsUnreadSocketBytes() throws IOException {
|
||||||
|
byte[] payload = "helloNEXT".getBytes(StandardCharsets.UTF_8);
|
||||||
|
ByteArrayInputStream socket = new ByteArrayInputStream(payload);
|
||||||
|
RequestBody body = new RequestBody(socket, 5, new byte[0], 0, 0);
|
||||||
|
body.drain();
|
||||||
|
assertArrayEquals("NEXT".getBytes(StandardCharsets.UTF_8), socket.readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_skipsOnlyRemainingAfterPartialPreBuf() throws IOException {
|
||||||
|
byte[] preBuf = "hel".getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] rest = "loNEXT".getBytes(StandardCharsets.UTF_8);
|
||||||
|
ByteArrayInputStream socket = new ByteArrayInputStream(rest);
|
||||||
|
// body = "hello" (5 bytes), 3 in preBuf, 2 from socket
|
||||||
|
RequestBody body = new RequestBody(socket, 5, preBuf, 0, 3);
|
||||||
|
body.drain();
|
||||||
|
// drain should skip 2 socket bytes ("lo"), leaving "NEXT"
|
||||||
|
assertArrayEquals("NEXT".getBytes(StandardCharsets.UTF_8), socket.readAllBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_chunked_drainsSocket() throws IOException {
|
||||||
|
byte[] data = "some chunked data".getBytes(StandardCharsets.UTF_8);
|
||||||
|
ByteArrayInputStream socket = new ByteArrayInputStream(data);
|
||||||
|
RequestBody body = new RequestBody(socket, -1L, null, 0, 0);
|
||||||
|
body.drain();
|
||||||
|
assertEquals(0, socket.available());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Benchmark Mode Cnt Score Error Units
|
||||||
Reference in New Issue
Block a user