optimized dynamic body size impl, decluttering javadocs/comments
This commit is contained in:
@@ -13,32 +13,17 @@ import java.io.*;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Entry point for the Flash HTTP server.
|
||||
*
|
||||
* <p>
|
||||
* Internally, the server owns a {@link GlobalRouter} with two tiers of routing:
|
||||
* <ol>
|
||||
* <li><b>Mounted sub-routers</b> — registered via {@link #mount}. Each owns a
|
||||
* namespace
|
||||
* prefix (e.g. {@code /api}) and handles all paths under it. Matched by longest
|
||||
* prefix first.</li>
|
||||
* <li><b>Internal router</b> — the fallback used when no sub-router claims the
|
||||
* path.
|
||||
* Routes registered directly on the server ({@link #get}, {@link #post}, etc.)
|
||||
* go here.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* 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<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
|
||||
|
||||
@@ -69,11 +55,7 @@ public class HttpServer {
|
||||
this.serverSocket = new ServerSocket(configuration.getPort());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the server on a new non-daemon virtual thread and returns a future
|
||||
* that completes
|
||||
* when the accept loop is running and the server is ready to serve requests.
|
||||
*/
|
||||
/** Returns a future that completes once the accept loop is running and the server is ready. */
|
||||
public CompletableFuture<Void> start() {
|
||||
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
|
||||
return readyFuture;
|
||||
@@ -94,10 +76,7 @@ public class HttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the server and waits for in-flight requests to complete.
|
||||
* The returned future is already completed when this method returns.
|
||||
*/
|
||||
/** Closes all active connections and shuts down the executor. Returns when complete. */
|
||||
public CompletableFuture<Void> stop() {
|
||||
stopped = true;
|
||||
try {
|
||||
@@ -105,6 +84,7 @@ public class HttpServer {
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing server socket", e);
|
||||
}
|
||||
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
||||
executorService.shutdown();
|
||||
try {
|
||||
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
|
||||
@@ -116,104 +96,66 @@ public class HttpServer {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
/**
|
||||
* Mounts a sub-router under the given namespace prefix.
|
||||
* Mounts a sub-router under {@code namespace}. Requests whose path starts with the
|
||||
* namespace are dispatched to {@code router}; longest prefix wins.
|
||||
*
|
||||
* <p>
|
||||
* 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 {
|
||||
|
||||
@@ -8,4 +8,6 @@ import lombok.Data;
|
||||
public class HttpServerConfiguration {
|
||||
private int port;
|
||||
private String host;
|
||||
@Builder.Default
|
||||
private int maxHeaderBufferSize = 64 * 1024;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String>. */
|
||||
public List<String> getAll(String name) {
|
||||
List<String> result = null;
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -52,7 +48,6 @@ public class HeaderMap {
|
||||
return result != null ? result : List.of();
|
||||
}
|
||||
|
||||
/** Lazy: decodes and returns all values as a List<String>. */
|
||||
public List<String> getAll() {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -61,7 +56,21 @@ public class HeaderMap {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Zero-copy: returns a ByteView over the raw value bytes without allocating a String. */
|
||||
public boolean headerValueEqualsIgnoreCase(String name, String value) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return false;
|
||||
int vs = values[idx * 2], vl = values[idx * 2 + 1];
|
||||
if (vl != value.length()) return false;
|
||||
for (int i = 0; i < vl; i++) {
|
||||
byte b = buffer[vs + i];
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
char c = value.charAt(i);
|
||||
if (c >= 'A' && c <= 'Z') c += 32;
|
||||
if (b != (byte) c) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public ByteView getView(String name) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return null;
|
||||
@@ -80,7 +89,6 @@ public class HeaderMap {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Case-insensitive comparison between a buffer slice and a String. Zero allocations. */
|
||||
private boolean keyMatches(int i, String name) {
|
||||
int ks = keys[i * 2], kl = keys[i * 2 + 1];
|
||||
if (kl != name.length()) return false;
|
||||
|
||||
@@ -5,14 +5,8 @@ import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
|
||||
/**
|
||||
* Deferred request body reader.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
@@ -5,11 +5,8 @@ import dev.relism.fpr.core.ByteView;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Path parameters captured during routing.
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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<String> getAll(String name) {
|
||||
if (raw == null) return List.of();
|
||||
List<String> result = null;
|
||||
@@ -70,10 +63,7 @@ public class QueryParams {
|
||||
return result != null ? result : List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans for the first occurrence of {@code name=value}.
|
||||
* Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. Zero allocations.
|
||||
*/
|
||||
/** Returns (valStart << 32) | valLen, or -1 if not found. */
|
||||
private long findFirst(String name) {
|
||||
if (raw == null) return -1L;
|
||||
int i = 0, len = raw.length();
|
||||
|
||||
@@ -31,17 +31,10 @@ public class Request {
|
||||
this.queryParams = null;
|
||||
}
|
||||
|
||||
/** Convenience constructor for test mocks and pre-materialized bodies. */
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
this(requestLine, LazyBody.of(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory used by {@link dev.relism.RequestParser}: creates a request whose body is read
|
||||
* from {@code stream} on the first call to {@link #getBody()}.
|
||||
*
|
||||
* @param preBufLen bytes already buffered past the header end (from the 8 KB read-ahead)
|
||||
*/
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
int contentLength, byte[] headerBuf,
|
||||
int bodyStart, int preBufLen) {
|
||||
@@ -51,30 +44,21 @@ public class Request {
|
||||
return new Request(requestLine, lazy);
|
||||
}
|
||||
|
||||
/** Materializes and returns the request body, reading from the socket if not yet done. */
|
||||
public byte[] getBody() { return lazyBody.get(); }
|
||||
|
||||
// --- Header access (lazy: String allocated only on call) ---
|
||||
|
||||
public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); }
|
||||
public List<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); }
|
||||
public List<String> getHeaders() { return requestLine.getHeaders().getAll(); }
|
||||
public boolean headerEquals(String name, String value) { return requestLine.getHeaders().headerValueEqualsIgnoreCase(name, value); }
|
||||
|
||||
// --- Path param access ---
|
||||
|
||||
/** Lazy: decodes the named path parameter to a {@code String}. */
|
||||
public String getPathParam(String name) {
|
||||
return pathParams != null ? pathParams.get(name) : null;
|
||||
}
|
||||
|
||||
// --- Query param access ---
|
||||
|
||||
/** Lazy: decodes the first value of {@code name}, or {@code null} if absent. */
|
||||
public String getQueryParam(String name) {
|
||||
return resolveQueryParams().get(name);
|
||||
}
|
||||
|
||||
/** Lazy: decodes all values of {@code name} (e.g. {@code ?tag=a&tag=b}). */
|
||||
public List<String> getQueryParams(String name) {
|
||||
return resolveQueryParams().getAll(name);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,9 @@ import lombok.Getter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Base class for all routers, used both for the server's internal router and for
|
||||
* mounted sub-routers.
|
||||
*
|
||||
* <p>Each router has a <b>namespace</b> (e.g. {@code /api}). Routes added via
|
||||
* {@link #get}, {@link #post}, etc. are <em>relative</em> to the namespace; the
|
||||
* implementation prepends it when registering. The namespace is {@code "/"} by default
|
||||
* and is set automatically by {@link GlobalRouter#mount}.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>The handler's return value drives the response:
|
||||
* <ul>
|
||||
* <li>Return a {@link Response} to replace the entire response object.</li>
|
||||
* <li>Return any other non-null value to use it as the body (via {@code toString()} or raw bytes).</li>
|
||||
* <li>Return {@code null} to leave the response unchanged from what was set on the {@code Response} parameter.</li>
|
||||
* </ul>
|
||||
* 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));
|
||||
}
|
||||
|
||||
@@ -14,16 +14,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Top-level dispatcher owned by {@link dev.relism.HttpServer}.
|
||||
*
|
||||
* <p>Routing order on each request:
|
||||
* <ol>
|
||||
* <li>Iterates mounted sub-routers in descending namespace length order (longest prefix first)
|
||||
* and delegates to the first one whose namespace is a prefix of the request path.</li>
|
||||
* <li>If no sub-router matches, falls through to the internal {@link FastPathRouterImpl}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<String, AbstractRouter> subRoutersMap = new HashMap<>();
|
||||
@@ -53,7 +45,6 @@ public class GlobalRouter extends AbstractRouter {
|
||||
return h != null ? h : notFoundHandler;
|
||||
}
|
||||
|
||||
/** Resolves the scoped exception handler for the router that owns the request path. */
|
||||
public ExceptionHandler resolveExceptionHandler(Request request) {
|
||||
ByteView path = request.getRequestLine().getPath();
|
||||
for (AbstractRouter sub : sortedSubRouters) {
|
||||
|
||||
+5
-24
@@ -12,30 +12,16 @@ import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.PathUtils;
|
||||
|
||||
/**
|
||||
* Router implementation backed by the {@code fpr-core} byte-level state machine.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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()}.
|
||||
*
|
||||
* <p>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<RequestHandler> builder = new RouterBuilder<>();
|
||||
private volatile FastPathRouter<ByteView, RequestHandler> router;
|
||||
private String[] cachedParamNames;
|
||||
|
||||
/**
|
||||
* Thread-local holders for per-request reusable objects.
|
||||
* Both {@link MatchResult} and {@link FastPathViews.MethodPathByteView} are reset before use,
|
||||
* so no allocation occurs on the routing hot path.
|
||||
*/
|
||||
private static final class FastPathRouterContext {
|
||||
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
|
||||
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
||||
@@ -55,7 +41,7 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
String fullPath = PathUtils.join(namespace, path);
|
||||
builder.add(StringRouteParser.parse(method.name() + fullPath), handler);
|
||||
this.router = null; // Mark dirty
|
||||
this.router = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -77,7 +63,6 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
return null;
|
||||
}
|
||||
|
||||
// CAPTURE PARAMETERS
|
||||
int count = result.paramCount();
|
||||
if (count > 0) {
|
||||
int methodLen = method.getBytes().length;
|
||||
@@ -97,10 +82,6 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
return result.handler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the FPR state machine is compiled. Uses double-checked locking on a {@code volatile}
|
||||
* field so the fast path (already compiled) is a single null-check with no synchronization.
|
||||
*/
|
||||
private void ensureCompiled() {
|
||||
if (router == null) {
|
||||
synchronized (this) {
|
||||
|
||||
@@ -5,17 +5,10 @@ import lombok.NoArgsConstructor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Container class for various {@link ByteView} implementations used by the FastPathRouter.
|
||||
* Consolidating these views reduces package clutter while maintaining high-performance byte access.
|
||||
*/
|
||||
/** {@link dev.relism.fpr.core.ByteView} implementations used on the router and parser hot paths. */
|
||||
@NoArgsConstructor
|
||||
public final class FastPathViews {
|
||||
|
||||
/**
|
||||
* High-performance, zero-copy ByteView that points to a shared request buffer.
|
||||
* Used by the {@link dev.relism.RequestParser} to scan for paths without allocations.
|
||||
*/
|
||||
public static final class RequestByteView implements ByteView {
|
||||
private final byte[] buffer;
|
||||
private final int start;
|
||||
@@ -41,24 +34,17 @@ public final class FastPathViews {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() { // CHANGED: decode slice directly; avoids full-buffer String copy
|
||||
public String toString() {
|
||||
return new String(buffer, start, length, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composite ByteView that prefixes a method's bytes to a path view.
|
||||
* Enables method+path routing in a single pass with no allocation.
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user