diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..af5f4de --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/docs/lifecycle/request/BODY.md b/docs/lifecycle/request/BODY.md new file mode 100644 index 0000000..30a5770 --- /dev/null +++ b/docs/lifecycle/request/BODY.md @@ -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()` | diff --git a/docs/lifecycle/request/MULTIPART.md b/docs/lifecycle/request/MULTIPART.md new file mode 100644 index 0000000..1e15294 --- /dev/null +++ b/docs/lifecycle/request/MULTIPART.md @@ -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 files = mp.parts("files"); // all parts named "files" +List 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 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 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); +``` diff --git a/docs/lifecycle/request/REQUEST.md b/docs/lifecycle/request/REQUEST.md new file mode 100644 index 0000000..b99d289 --- /dev/null +++ b/docs/lifecycle/request/REQUEST.md @@ -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 accepts = req.headers("Accept"); // all values in order +List 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 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. diff --git a/docs/lifecycle/response/RESPONSE.md b/docs/lifecycle/response/RESPONSE.md new file mode 100644 index 0000000..4ef0c7e --- /dev/null +++ b/docs/lifecycle/response/RESPONSE.md @@ -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 | diff --git a/flash-bench/dependency-reduced-pom.xml b/flash-bench/dependency-reduced-pom.xml new file mode 100644 index 0000000..01864f1 --- /dev/null +++ b/flash-bench/dependency-reduced-pom.xml @@ -0,0 +1,58 @@ + + + + flash-parent + dev.relism + 1.0-SNAPSHOT + + 4.0.0 + flash-bench + + flash-bench + + + maven-shade-plugin + 3.5.3 + + + package + + shade + + + + + dev.relism.bench.Main + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + org.projectlombok + lombok + 1.18.44 + provided + + + + 21 + UTF-8 + 2.12.3 + + diff --git a/flash-bench/src/main/java/dev/relism/bench/Main.java b/flash-bench/src/main/java/dev/relism/bench/Main.java new file mode 100644 index 0000000..8483367 --- /dev/null +++ b/flash-bench/src/main/java/dev/relism/bench/Main.java @@ -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; + } +} \ No newline at end of file diff --git a/flash-bench/src/main/resources/element.json b/flash-bench/src/main/resources/element.json new file mode 100644 index 0000000..0353f15 --- /dev/null +++ b/flash-bench/src/main/resources/element.json @@ -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} +} diff --git a/flash-bench/src/main/resources/shells.json b/flash-bench/src/main/resources/shells.json new file mode 100644 index 0000000..e645b57 --- /dev/null +++ b/flash-bench/src/main/resources/shells.json @@ -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]} +} diff --git a/flash/src/main/java/dev/relism/ChunkedInputStream.java b/flash/src/main/java/dev/relism/ChunkedInputStream.java new file mode 100644 index 0000000..1bf459c --- /dev/null +++ b/flash/src/main/java/dev/relism/ChunkedInputStream.java @@ -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 + } + } +} diff --git a/flash/src/main/java/dev/relism/api/multipart/Multipart.java b/flash/src/main/java/dev/relism/api/multipart/Multipart.java new file mode 100644 index 0000000..d4c05fb --- /dev/null +++ b/flash/src/main/java/dev/relism/api/multipart/Multipart.java @@ -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. + * + *

Reads from {@code body.stream()} — the request body is never 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. + * + *

{@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 files = mp.parts("files");
+ * }
+ * + *

Scan ordering: {@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. + * + *

Thread safety: 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--" + + private final byte[] win; + private int wPos = 0; + private int wLen = 0; + private boolean srcEof = false; + private boolean done = false; + + private final List 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 parts(String name) throws IOException { + scanAll(); + List 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 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 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 readPartHeaders() throws IOException { + Map 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(); + } +} diff --git a/flash/src/main/java/dev/relism/api/multipart/Part.java b/flash/src/main/java/dev/relism/api/multipart/Part.java new file mode 100644 index 0000000..0e9405f --- /dev/null +++ b/flash/src/main/java/dev/relism/api/multipart/Part.java @@ -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}. + * + *

Two internal states: + *

    + *
  • Buffered (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.
  • + *
  • Streaming (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}.
  • + *
+ */ +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); + } +} diff --git a/flash/src/main/java/dev/relism/models/RequestBody.java b/flash/src/main/java/dev/relism/models/RequestBody.java new file mode 100644 index 0000000..c57a21d --- /dev/null +++ b/flash/src/main/java/dev/relism/models/RequestBody.java @@ -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: + * + *
    + *
  • {@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.
  • + *
  • {@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.
  • + *
+ * + *

Mutual exclusivity: calling both {@code bytes()} and {@code stream()} on the same + * request produces undefined results. Choose one mode per handler. + * + *

Keep-alive: 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. + * + *

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. + * + *

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. + * + *

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. + * + *

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; + } + }; + } +} diff --git a/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java b/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java new file mode 100644 index 0000000..9b11fa2 --- /dev/null +++ b/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java @@ -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)); + } +} diff --git a/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java b/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java new file mode 100644 index 0000000..29a09fc --- /dev/null +++ b/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java @@ -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 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 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)); + } +} diff --git a/flash/src/test/java/dev/relism/models/RequestBodyTest.java b/flash/src/test/java/dev/relism/models/RequestBodyTest.java new file mode 100644 index 0000000..4c61aad --- /dev/null +++ b/flash/src/test/java/dev/relism/models/RequestBodyTest.java @@ -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()); + } +} diff --git a/jmh-result.text b/jmh-result.text new file mode 100644 index 0000000..48aabd1 --- /dev/null +++ b/jmh-result.text @@ -0,0 +1 @@ +Benchmark Mode Cnt Score Error Units