- * Internally, the server owns a {@link GlobalRouter} with two tiers of routing: - *
- * Error handlers ({@link #onNotFound}, {@link #onException}) registered on the
- * server
- * apply only to the internal router. Each mounted sub-router has its own
- * independent handlers.
+ * Flash HTTP server. Owns a {@link GlobalRouter} with two routing tiers:
+ * mounted sub-routers (matched by longest namespace prefix) and an internal
+ * router as fallback. Error handlers are scoped to their respective router.
*/
@Slf4j
public class HttpServer {
@@ -46,6 +31,7 @@ public class HttpServer {
private final ServerSocket serverSocket;
private final GlobalRouter globalRouter = new GlobalRouter();
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
+ private final Set
- * Any request whose path starts with {@code namespace} will be dispatched to
- * {@code router}
- * instead of the internal router. When multiple namespaces match, the longest
- * prefix wins.
- * The router's own {@code onNotFound} and {@code onException} handlers are used
- * for its paths —
- * the server-level handlers do not apply.
- *
- * @throws dev.relism.exceptions.DuplicateNamespaceException if
- * {@code namespace}
- * is already mounted
+ * @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted
*/
public HttpServer mount(String namespace, AbstractRouter router) {
globalRouter.mount(namespace, router);
return this;
}
- // --- Global error handlers ---
-
- /**
- * Sets the 404 handler for routes registered directly on this server.
- * Does not affect mounted sub-routers, which each carry their own not-found
- * handler.
- */
public HttpServer onNotFound(SimpleHandler.FunctionalHandler handler) {
globalRouter.onNotFound(handler);
return this;
}
- /**
- * Sets the exception handler for routes registered directly on this server.
- * Does not affect mounted sub-routers, which each carry their own exception
- * handler.
- */
public HttpServer onException(AbstractRouter.ExceptionHandler handler) {
globalRouter.onException(handler);
return this;
}
- // --- DSL ---
-
- /**
- * Registers an annotation-based handler on the internal router.
- * The handler's class must carry a {@link dev.relism.routing.Route @Route}
- * annotation
- * declaring the HTTP method and path. To register on a specific sub-router,
- * call
- * {@link AbstractRouter#register} on that router directly.
- */
public HttpServer register(RequestHandler handler) {
globalRouter.register(handler);
return this;
}
/**
- * Registers a route on the internal router (not on any mounted sub-router).
- * The handler's return value is used as the response body; returning a
- * {@link dev.relism.models.Response}
- * instance replaces the entire response object.
+ * Registers a route on the internal router. The handler return value drives the response:
+ * return a {@link dev.relism.models.Response} to replace it entirely, any other non-null
+ * value to set it as the body, or {@code null} to leave the response object unchanged.
*/
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.get(path, h);
return this;
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.post(path, h);
return this;
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.put(path, h);
return this;
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) {
globalRouter.delete(path, h);
return this;
}
- // --- Request processing ---
-
private void process(Socket socket) {
+ activeSockets.add(socket);
executorService.submit(() -> {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
+ RequestParser parser = new RequestParser(configuration.getMaxHeaderBufferSize());
while (!stopped) {
- Request request = RequestParser.parse(in);
+ Request request = parser.parse(in);
if (request == null)
break;
@@ -236,7 +178,6 @@ public class HttpServer {
response.setBody(result);
}
- // Drain any unread body bytes so the stream is positioned at the next request.
request.getBody();
out.write(HTTP_1_1);
@@ -260,24 +201,18 @@ public class HttpServer {
} catch (IOException e) {
if (!stopped)
log.error("I/O error handling request", e);
+ } finally {
+ activeSockets.remove(socket);
}
});
}
- /**
- * Returns true if the connection should be kept alive after this request.
- * HTTP/1.1 default: keep-alive. HTTP/1.0 default: close.
- * Explicit {@code Connection} header overrides the default.
- */
private static boolean isKeepAlive(Request request) {
- String connection = request.getHeader("Connection");
- if (connection != null) {
- return !"close".equalsIgnoreCase(connection);
- }
- // Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1',
- // "HTTP/1.0" → '0'
+ if (request.headerEquals("Connection", "close")) return false;
+ // Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1', "HTTP/1.0" → '0'
ByteView protocol = request.getRequestLine().getProtocol();
- return protocol.length() == 8 && protocol.byteAt(7) == '1';
+ return protocol.length() == 8 && protocol.byteAt(7) == '1'
+ || request.headerEquals("Connection", "keep-alive");
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
diff --git a/flash/src/main/java/dev/relism/HttpServerConfiguration.java b/flash/src/main/java/dev/relism/HttpServerConfiguration.java
index 3a2d9f8..c229202 100644
--- a/flash/src/main/java/dev/relism/HttpServerConfiguration.java
+++ b/flash/src/main/java/dev/relism/HttpServerConfiguration.java
@@ -8,4 +8,6 @@ import lombok.Data;
public class HttpServerConfiguration {
private int port;
private String host;
+ @Builder.Default
+ private int maxHeaderBufferSize = 64 * 1024;
}
diff --git a/flash/src/main/java/dev/relism/RequestParser.java b/flash/src/main/java/dev/relism/RequestParser.java
index 6ff4eb8..828a5f8 100644
--- a/flash/src/main/java/dev/relism/RequestParser.java
+++ b/flash/src/main/java/dev/relism/RequestParser.java
@@ -10,25 +10,35 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
+import java.util.Arrays;
/**
- * Extreme Zero-Allocation Request Parser.
- * Scans raw bytes to identify paths, protocols, and headers without intermediate String objects.
- * Uses buffered reading and direct byte comparisons for maximum performance.
+ * One instance per connection — the buffer is allocated once and reused across keep-alive
+ * requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String
+ * allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices.
*/
@Slf4j
public class RequestParser {
- private static final int MAX_HEADER_SIZE = 8192;
+ private static final int INITIAL_BUFFER_SIZE = 8192;
- public static Request parse(InputStream in) throws IOException {
- byte[] buffer = new byte[MAX_HEADER_SIZE];
+ private final int maxHeaderBufferSize;
+ private byte[] buffer;
- // 1. Robust read loop — accumulate until \r\n\r\n found or buffer full.
- // prevTotal tracked per iteration so findEndOfHeader starts from max(0, prevTotal-3),
- // skipping already-scanned bytes. The -3 overlap catches \r\n\r\n split across two reads.
+ public RequestParser() { this(64 * 1024); }
+ public RequestParser(int maxHeaderBufferSize) {
+ this.maxHeaderBufferSize = maxHeaderBufferSize;
+ this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
+ }
+
+ public Request parse(InputStream in) throws IOException {
int totalRead = 0;
int headerEndIdx = -1;
- while (totalRead < buffer.length) {
+ while (true) {
+ if (totalRead == buffer.length) {
+ if (buffer.length >= maxHeaderBufferSize)
+ throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
+ buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
+ }
int n = in.read(buffer, totalRead, buffer.length - totalRead);
if (n <= 0) break;
int prevTotal = totalRead;
@@ -38,10 +48,9 @@ public class RequestParser {
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
- throw new IOException("Headers too large: buffer exhausted without finding \\r\\n\\r\\n");
+ throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
}
- // 2. Scan Request Line: METHOD PATH PROTOCOL
int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
@@ -52,7 +61,6 @@ public class RequestParser {
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
- // Split path from query string at '?' — FPR only sees the clean path
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
@@ -66,7 +74,6 @@ public class RequestParser {
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
- // 3. Scan Headers — store raw offsets into buffer, zero objects allocated per header
HeaderMap headerMap = new HeaderMap(buffer);
int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int contentLength = 0;
@@ -82,16 +89,13 @@ public class RequestParser {
headerMap.add(current, colon - current, valueStart, lineEnd - valueStart);
- // Direct byte comparison to extract Content-Length without String allocation
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
contentLength = parseInt(buffer, valueStart, lineEnd);
}
}
- current = lineEnd + 2; // skip \r\n
+ current = lineEnd + 2;
}
- // Body is read lazily — only materialized if the handler calls req.getBody().
- // Bytes already in the buffer past \r\n\r\n are handed off to LazyBody as read-ahead.
int bodyStart = headerEndIdx + 4;
int preBufLen = totalRead - bodyStart;
return Request.forParsed(
diff --git a/flash/src/main/java/dev/relism/models/HeaderMap.java b/flash/src/main/java/dev/relism/models/HeaderMap.java
index 7454401..24690bb 100644
--- a/flash/src/main/java/dev/relism/models/HeaderMap.java
+++ b/flash/src/main/java/dev/relism/models/HeaderMap.java
@@ -8,8 +8,7 @@ import java.util.List;
/**
* Zero-allocation header storage backed by raw byte offsets into the shared request buffer.
- * At parse time, only int offsets are recorded — zero objects allocated per header.
- * String conversion is lazy: happens only when getFirst() or getAll() are called.
+ * Offsets are recorded at parse time; Strings are allocated only on {@link #getFirst} / {@link #getAll}.
*/
public class HeaderMap {
private static final int MAX_HEADERS = 32;
@@ -23,7 +22,6 @@ public class HeaderMap {
this.buffer = buffer;
}
- /** Called at parse time. Stores raw offsets — zero allocations. */
public void add(int keyStart, int keyLen, int valStart, int valLen) {
if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS);
keys[count * 2] = keyStart;
@@ -33,14 +31,12 @@ public class HeaderMap {
count++;
}
- /** Lazy: decodes and returns the first matching value as a String. */
public String getFirst(String name) {
int idx = indexOf(name);
if (idx < 0) return null;
return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8);
}
- /** Lazy: decodes and returns all matching values as a List On the first call to {@link #get()}, the body is materialized: any bytes already
- * buffered from the 8 KB header read-ahead are copied first, then the remainder is pulled
- * from the socket stream. The result is cached so subsequent calls are free.
- *
- * When {@code contentLength} is zero the instance is pre-resolved to an empty array
- * and no stream access ever occurs.
+ * Deferred body reader. On the first {@link #get()} call, pre-buffered bytes from the header
+ * read-ahead are used first, then the remainder is pulled from the socket stream. Result is cached.
*/
final class LazyBody {
private static final byte[] EMPTY = new byte[0];
@@ -32,14 +26,12 @@ final class LazyBody {
this.preBufLen = preBufLen;
}
- /** Returns a pre-resolved {@code LazyBody} backed by an already-materialized byte array. */
static LazyBody of(byte[] bytes) {
LazyBody lb = new LazyBody(null, bytes.length, null, 0, 0);
lb.resolved = bytes;
return lb;
}
- /** Returns a pre-resolved empty {@code LazyBody}. */
static LazyBody empty() {
LazyBody lb = new LazyBody(null, 0, null, 0, 0);
lb.resolved = EMPTY;
diff --git a/flash/src/main/java/dev/relism/models/PathParams.java b/flash/src/main/java/dev/relism/models/PathParams.java
index 367072c..24e9b68 100644
--- a/flash/src/main/java/dev/relism/models/PathParams.java
+++ b/flash/src/main/java/dev/relism/models/PathParams.java
@@ -5,11 +5,8 @@ import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
/**
- * Path parameters captured during routing.
- *
- * Values are stored as byte offsets into the original path view. String conversion
- * is lazy and happens only on {@link #get}; {@link #view} is zero-copy.
- * Lookup is a linear scan — param counts are always small (typically 1–3).
+ * Path parameters captured during routing, stored as byte offsets into the path view.
+ * {@link #get} allocates a String on call; {@link #view} is zero-copy.
*/
public class PathParams {
private final ByteView source;
@@ -24,7 +21,6 @@ public class PathParams {
this.lens = lens;
}
- /** Lazy — allocates a {@code String} only on call. {@code null} if the param is absent. */
public String get(String name) {
int i = indexOf(name);
if (i < 0) return null;
@@ -33,7 +29,6 @@ public class PathParams {
return new String(bytes, StandardCharsets.UTF_8);
}
- /** Zero-copy — returns a {@link ByteView} slice over the raw path bytes. */
ByteView view(String name) {
int i = indexOf(name);
if (i < 0) return null;
diff --git a/flash/src/main/java/dev/relism/models/QueryParams.java b/flash/src/main/java/dev/relism/models/QueryParams.java
index 2170375..1b2e07f 100644
--- a/flash/src/main/java/dev/relism/models/QueryParams.java
+++ b/flash/src/main/java/dev/relism/models/QueryParams.java
@@ -7,14 +7,10 @@ import java.util.ArrayList;
import java.util.List;
/**
- * Lazy access to URL query parameters ({@code ?key=value&...}).
- * Backed by a zero-copy {@link ByteView} over the raw query string bytes from the request buffer.
- * No parsing happens at construction — values are decoded on demand.
- *
- * {@link #view} is zero-copy; {@link #get} and {@link #getAll} allocate only the result String(s).
+ * Lazy query parameter access ({@code ?key=value&...}). Backed by a zero-copy {@link ByteView}
+ * over the raw query string bytes — no parsing at construction, values decoded on demand.
*/
public class QueryParams {
- /** Singleton returned when the request has no query string. All methods return empty/null. */
public static final QueryParams EMPTY = new QueryParams(null);
private final ByteView raw;
@@ -23,7 +19,6 @@ public class QueryParams {
this.raw = raw;
}
- /** Lazy — decodes the first value for {@code name}, or {@code null} if absent. */
public String get(String name) {
long r = findFirst(name);
if (r < 0) return null;
@@ -33,7 +28,6 @@ public class QueryParams {
return new String(bytes, StandardCharsets.UTF_8);
}
- /** Zero-copy — returns a {@link ByteView} slice over the raw value bytes. */
ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
@@ -44,7 +38,6 @@ public class QueryParams {
};
}
- /** Lazy — returns all values for {@code name}, or an empty list if absent. */
public List Each router has a namespace (e.g. {@code /api}). Routes added via
- * {@link #get}, {@link #post}, etc. are relative to the namespace; the
- * implementation prepends it when registering. The namespace is {@code "/"} by default
- * and is set automatically by {@link GlobalRouter#mount}.
- *
- * Error handlers ({@link #onNotFound}, {@link #onException}) are scoped to this router.
- * When a sub-router is mounted on the server, its handlers take precedence over the
- * server-level ones for all paths under its namespace.
+ * Base router. Each router has a namespace prefix (default {@code "/"}); routes added via
+ * {@link #get}, {@link #post}, etc. are relative to it. Error handlers are scoped to this router.
+ * The namespace is set automatically by {@link GlobalRouter#mount}.
*/
public abstract class AbstractRouter {
@@ -32,8 +24,6 @@ public abstract class AbstractRouter {
@Getter(AccessLevel.PACKAGE)
protected byte[] namespaceBytes = new byte[]{ '/' };
- // --- Default handlers ---
-
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
res.setStatusCode(404);
res.setContentType(ContentType.TEXT_HTML);
@@ -46,7 +36,6 @@ public abstract class AbstractRouter {
return ErrorPages.renderException(req, ex);
};
- // Package-private — used by GlobalRouter only
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
void setNamespace(String namespace) {
@@ -54,54 +43,37 @@ public abstract class AbstractRouter {
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
}
- // --- Public API ---
-
- /** Overrides the default 404 response for unmatched paths under this router's namespace. */
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
this.notFoundHandler = new SimpleHandler(handler);
return this;
}
- /** Overrides the default 500 response for uncaught exceptions thrown by handlers under this router. */
public AbstractRouter onException(ExceptionHandler handler) {
this.exceptionHandler = handler;
return this;
}
/**
- * Registers a route relative to this router's namespace.
- *
- * The handler's return value drives the response:
- * Routing order on each request:
- * Not intended to be instantiated or subclassed directly — use {@link dev.relism.HttpServer}.
+ * Top-level dispatcher. Routes to the longest-matching mounted sub-router first,
+ * falling back to the internal {@link FastPathRouterImpl}.
*/
public class GlobalRouter extends AbstractRouter {
private final Map Routes are compiled lazily: the internal {@code FastPathRouter} is built on the first
- * incoming request after one or more routes have been added. Adding a route after the server
- * has started is safe — the router is marked dirty and recompiled on the next request.
- *
- * Matching is done on a virtual {@code METHOD + path} byte sequence to avoid a two-step
- * lookup. Captured path parameters are exposed via {@link dev.relism.models.PathParams} on
- * the request object, accessible through {@link dev.relism.models.Request#getPathParams()}.
- *
- * Thread safety: {@code FastPathRouter.match()} is safe for concurrent calls as of
- * {@code fpr-core} 1.1.0 — traversal state ({@code RouteSearch}, {@code SegmentCursor}) is
- * held in method-local variables, making the compiled router instance fully immutable.
+ * Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
+ * on the first request and recompiled when routes are added after startup. Matching runs on a
+ * virtual {@code METHOD + path} byte sequence in a single pass; {@link MatchResult} and
+ * {@link FastPathViews.MethodPathByteView} are reused per-thread to avoid hot-path allocations.
*/
public class FastPathRouterImpl extends AbstractRouter {
private final RouterBuilder Mutable by design — reused across requests via {@code ThreadLocal}.
- * Call {@link #reset} before each use.
- */
+ /** Mutable composite view: method bytes + path. Reused via ThreadLocal — call reset() before use. */
public static final class MethodPathByteView implements ByteView {
private byte[] method;
private ByteView path;
private int totalLength;
- /** Binds this view to a new method+path pair. Must be called before each use. */
public void reset(byte[] method, ByteView path) {
this.method = method;
this.path = path;
@@ -76,9 +62,6 @@ public final class FastPathViews {
}
}
- /**
- * ByteView implementation for raw byte arrays, typically from a socket.
- */
public static class SocketByteView implements ByteView {
private final byte[] data;
@@ -97,9 +80,6 @@ public final class FastPathViews {
}
}
- /**
- * ByteView implementation for Java Strings.
- */
public static class StringByteView implements ByteView {
private final byte[] bytes;
diff --git a/flash/src/test/java/dev/relism/HttpServerTest.java b/flash/src/test/java/dev/relism/HttpServerTest.java
index c5c8d97..50548fa 100644
--- a/flash/src/test/java/dev/relism/HttpServerTest.java
+++ b/flash/src/test/java/dev/relism/HttpServerTest.java
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
@@ -62,17 +63,32 @@ class HttpServerTest {
try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
-
+
out.write(rawHttp.getBytes(StandardCharsets.UTF_8));
out.flush();
- java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
- byte[] buffer = new byte[8192];
- int read;
- while ((read = in.read(buffer)) != -1) {
- baos.write(buffer, 0, read);
+ // Read until \r\n\r\n to get the full header block
+ ByteArrayOutputStream headerBuf = new ByteArrayOutputStream();
+ int b, prev3 = -1, prev2 = -1, prev1 = -1;
+ while ((b = in.read()) != -1) {
+ headerBuf.write(b);
+ if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
+ prev3 = prev2; prev2 = prev1; prev1 = b;
}
- return baos.toString(StandardCharsets.UTF_8);
+ String headers = headerBuf.toString(StandardCharsets.UTF_8);
+
+ // Parse Content-Length
+ int contentLength = 0;
+ for (String line : headers.split("\r\n")) {
+ if (line.toLowerCase().startsWith("content-length:")) {
+ contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());
+ break;
+ }
+ }
+
+ // Read exactly Content-Length bytes for the body
+ byte[] body = in.readNBytes(contentLength);
+ return headers + new String(body, StandardCharsets.UTF_8);
}
}
diff --git a/flash/src/test/java/dev/relism/RequestParserTest.java b/flash/src/test/java/dev/relism/RequestParserTest.java
index 846221e..a1bcf4b 100644
--- a/flash/src/test/java/dev/relism/RequestParserTest.java
+++ b/flash/src/test/java/dev/relism/RequestParserTest.java
@@ -16,7 +16,7 @@ class RequestParserTest {
private static Request parse(String raw) throws IOException {
byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
- return RequestParser.parse(new ByteArrayInputStream(bytes));
+ return new RequestParser().parse(new ByteArrayInputStream(bytes));
}
private static String req(String requestLine, String... headers) {
@@ -70,7 +70,7 @@ class RequestParserTest {
void body_parsed() throws IOException {
String body = "hello body";
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
- Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8));
}
@@ -85,14 +85,14 @@ class RequestParserTest {
@Test
void emptyInputStream_returnsNull() throws IOException {
- assertNull(RequestParser.parse(new ByteArrayInputStream(new byte[0])));
+ assertNull(new RequestParser().parse(new ByteArrayInputStream(new byte[0])));
}
@Test
void missingHeaderTerminator_throwsIOException() {
// Valid request line but stream ends before \r\n\r\n
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
- assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(raw)));
+ assertThrows(IOException.class, () -> new RequestParser().parse(new ByteArrayInputStream(raw)));
}
@Test
@@ -107,11 +107,12 @@ class RequestParserTest {
}
@Test
- void headersOverBufferSize_throwsIOException() {
- // 9 KB of data with no \r\n\r\n exhausts the 8 KB buffer
- byte[] giant = new byte[9000];
+ void headers_exceedingMaxBufferSize_throwsIOException() {
+ // Feed more bytes than the configured cap with no \r\n\r\n — must throw
+ int cap = 16 * 1024;
+ byte[] giant = new byte[cap + 1];
Arrays.fill(giant, (byte) 'A');
- assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(giant)));
+ assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant)));
}
@Test
@@ -119,7 +120,7 @@ class RequestParserTest {
// Content-Length claims 50 but stream ends after 5 bytes
String body = "hello";
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
- Request r = RequestParser.parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
+ Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r);
assertEquals(50, r.getBody().length);
assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8));
- *
+ * Registers a route relative to this router's namespace. Return a {@link Response} to replace
+ * it entirely, any other non-null value to set it as the body, or {@code null} to leave it unchanged.
*/
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler));
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler));
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler));
}
- /** @see #get(String, SimpleHandler.FunctionalHandler) */
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler));
}
- /**
- * Registers a class-based handler. The handler's class must be annotated with
- * {@link Route @Route} declaring the HTTP method and path (relative to this router's namespace).
- * If the annotation is absent, the call is silently ignored.
- */
+ /** Registers a class-based handler; the class must be annotated with {@link Route @Route}. */
public AbstractRouter register(RequestHandler handler) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null) {
@@ -110,18 +82,10 @@ public abstract class AbstractRouter {
return this;
}
- // --- For implementors ---
-
public abstract RequestHandler route(Request request);
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
- /**
- * Sets the path parameters captured during routing.
- * Implementations must call this whenever the matched route contains path parameters.
- * Centralised here so all router implementations participate in the same contract
- * and produce a consistent {@link PathParams} regardless of the matching strategy.
- */
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
request.setPathParams(new PathParams(source, names, starts, lens));
}
diff --git a/flash/src/main/java/dev/relism/routing/GlobalRouter.java b/flash/src/main/java/dev/relism/routing/GlobalRouter.java
index c06bb07..5cd3d65 100644
--- a/flash/src/main/java/dev/relism/routing/GlobalRouter.java
+++ b/flash/src/main/java/dev/relism/routing/GlobalRouter.java
@@ -14,16 +14,8 @@ import java.util.List;
import java.util.Map;
/**
- * Top-level dispatcher owned by {@link dev.relism.HttpServer}.
- *
- *
- *
- *
- *