commit 79e45787310f109c4c8dbf2d908a380448470cbb Author: Relism Date: Sun Mar 15 01:54:35 2026 +0100 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..480bdf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..f938323 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..12f302d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f06836d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +# Build the project +mvn compile + +# Package (JAR) +mvn package + +# Run (entry point is dev.relism.Main) +mvn exec:java -Dexec.mainClass="dev.relism.Main" + +# Clean build artifacts +mvn clean + +# Full clean build +mvn clean package +``` + +No tests exist yet. The project resolves the `fpr-core` dependency from a private Reposilite repository at `https://maven.relism.dev/releases`. + +## Architecture Overview + +**Flash** is a hand-rolled, zero-allocation HTTP/1.1 server built on raw Java sockets with virtual threads (Java 21). The central design goal is extreme low latency — no intermediate String allocations on the hot path. + +### Request Lifecycle + +``` +Socket → RequestParser → GlobalRouter → AbstractRouter impl → RequestHandler → Response bytes → Socket +``` + +1. **`RequestParser`** — Reads raw bytes from the socket into an 8 KB buffer and scans for method, path, headers, and body without creating intermediate String objects. All parsed values are wrapped in `ByteView` implementations (see `FastPathViews`). + +2. **`GlobalRouter`** — The top-level dispatcher. Holds a sorted list of mounted sub-routers (longest namespace prefix first) and a default internal router. Dispatches each request via byte-level prefix matching (`ByteUtils.startsWith`). + +3. **`FastPathRouterImpl`** — The primary router implementation, backed by the external `fpr-core` library. Builds a compiled state machine (`RouterBuilder → FastPathRouter`) for ultra-fast byte-level method+path matching. Routes are marked dirty (`router = null`) when added and lazily recompiled on first request. Thread-local `MatchResult` objects avoid per-request allocation. + +4. **`RadixPathRouterImpl`** — Stub; not yet implemented. + +### Key Abstractions + +- **`AbstractRouter`** — Base class for all routers. Has a `namespace` (byte array for zero-alloc prefix checks) and two abstract methods: `route(Request)` and `addRoute(HttpMethod, String, RequestHandler)`. +- **`RequestHandler`** — Abstract class handlers extend. The `handle(Request, Response)` method returns either a `Response` object or any other value (serialized as the body) or `null`. +- **`SimpleHandler`** — Wraps a `FunctionalHandler` lambda for the fluent DSL (`server.get(path, handler)`). +- **`@Route` annotation** — Applied to `RequestHandler` subclasses to declare their HTTP method and path. Used by `AbstractRouter.register()`. +- **`ByteView`** (`fpr-core` interface) — Zero-copy abstraction over byte sequences. Implemented by `RequestByteView` (slice into the raw buffer), `MethodPathByteView` (virtual concatenation of method + path), `SocketByteView`, and `StringByteView` — all in `FastPathViews`. + +### Routing Registration (Two Styles) + +**Annotation-based (class handlers):** +```java +@Route(method = "GET", path = "/profile") +public static class ProfileHandler extends RequestHandler { ... } + +apiRouter.register(new ProfileHandler()); // namespace "/api" + path "/profile" = "/api/profile" +``` + +**Fluent DSL (lambdas):** +```java +server.get("/hello", (req, res) -> "Hello World"); +``` + +Paths registered via `addRoute` are prefixed with the router's namespace inside `FastPathRouterImpl.addRoute`. + +### Response Writing + +`HttpServer` writes HTTP/1.1 responses with pre-calculated static byte arrays (`HTTP_1_1`, `CRLF`, `CONTENT_TYPE`, `CONTENT_LENGTH`) to avoid per-response allocations. `IoUtils.writeInt` writes integer values without String conversion. + +### Performance Invariants to Maintain + +- No `String` creation on the routing hot path (use `ByteView` and byte arrays). +- Thread-local `MatchResult` reuse in `FastPathRouterContext` — do not allocate per request. +- `GlobalRouter.route` must stay allocation-free (sorted list iterated directly). +- `RequestParser` assumes headers fit in one 8 KB burst read. diff --git a/dev/relism/fpr/core/FastPathRouter.class b/dev/relism/fpr/core/FastPathRouter.class new file mode 100644 index 0000000..73c7291 Binary files /dev/null and b/dev/relism/fpr/core/FastPathRouter.class differ diff --git a/dev/relism/fpr/core/internal/runtime/FrozenRouter.class b/dev/relism/fpr/core/internal/runtime/FrozenRouter.class new file mode 100644 index 0000000..217a6ca Binary files /dev/null and b/dev/relism/fpr/core/internal/runtime/FrozenRouter.class differ diff --git a/dev/relism/fpr/core/internal/runtime/RouteSearch.class b/dev/relism/fpr/core/internal/runtime/RouteSearch.class new file mode 100644 index 0000000..98f1203 Binary files /dev/null and b/dev/relism/fpr/core/internal/runtime/RouteSearch.class differ diff --git a/dev/relism/fpr/core/internal/runtime/SegmentCursor.class b/dev/relism/fpr/core/internal/runtime/SegmentCursor.class new file mode 100644 index 0000000..dfcf53b Binary files /dev/null and b/dev/relism/fpr/core/internal/runtime/SegmentCursor.class differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bc392c5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + dev.relism + Flash + 1.0-SNAPSHOT + pom + + flash-bench + + + + 21 + 21 + UTF-8 + + + + + reposilite-repository-releases + Reposilite Repository + https://maven.relism.dev/releases + + + + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + org.projectlombok + lombok + 1.18.44 + provided + + + dev.relism + fpr-core + 1.1.0 + + + + org.slf4j + slf4j-api + 2.0.16 + + + + org.slf4j + slf4j-simple + 2.0.16 + runtime + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.44 + + + + + + + + \ No newline at end of file diff --git a/src/main/java/dev/relism/Flash.java b/src/main/java/dev/relism/Flash.java new file mode 100644 index 0000000..84832bf --- /dev/null +++ b/src/main/java/dev/relism/Flash.java @@ -0,0 +1,8 @@ +package dev.relism; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public final class Flash { + public static final String VERSION = "5.0.0-dev"; +} diff --git a/src/main/java/dev/relism/HttpServer.java b/src/main/java/dev/relism/HttpServer.java new file mode 100644 index 0000000..81df8fa --- /dev/null +++ b/src/main/java/dev/relism/HttpServer.java @@ -0,0 +1,227 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.http.HttpStatus; +import dev.relism.models.*; +import dev.relism.routing.AbstractRouter; +import dev.relism.routing.GlobalRouter; + +import lombok.extern.slf4j.Slf4j; + +import java.io.*; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Entry point for the Flash HTTP server. + * + *

Internally, the server owns a {@link GlobalRouter} with two tiers of routing: + *

    + *
  1. Mounted sub-routers — registered via {@link #mount}. Each owns a namespace + * prefix (e.g. {@code /api}) and handles all paths under it. Matched by longest prefix first.
  2. + *
  3. Internal router — the fallback used when no sub-router claims the path. + * Routes registered directly on the server ({@link #get}, {@link #post}, etc.) go here.
  4. + *
+ * + *

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. + */ +@Slf4j +public class HttpServer { + private final HttpServerConfiguration configuration; + private final ServerSocket serverSocket; + private final GlobalRouter globalRouter = new GlobalRouter(); + private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + private volatile boolean stopped = false; + private final CompletableFuture readyFuture = new CompletableFuture<>(); + + private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); + + private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8); + + private static final byte[][] DIGITS = new byte[10][1]; + static { + for (int i = 0; i < 10; i++) DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8); + } + + public HttpServer(HttpServerConfiguration configuration) throws IOException { + this.configuration = configuration; + 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. + */ + public CompletableFuture start() { + Thread.ofVirtual().name("flash-accept-loop").start(this::run); + return readyFuture; + } + + private void run() { + log.info("Server started on port {}", configuration.getPort()); + try (executorService) { + readyFuture.complete(null); + while (!stopped) { + Socket clientSocket = serverSocket.accept(); + process(clientSocket); + } + } catch (IOException e) { + if (!stopped) { + readyFuture.completeExceptionally(e); + log.error("Accept loop error", e); + } + } + } + + /** + * Stops the server and waits for in-flight requests to complete. + * The returned future is already completed when this method returns. + */ + public CompletableFuture stop() { + stopped = true; + try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); } + executorService.shutdown(); + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) executorService.shutdownNow(); + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + return CompletableFuture.completedFuture(null); + } + + // --- Routing --- + + /** + * Mounts a sub-router under the given namespace prefix. + * + *

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 + */ + 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. + */ + 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) { + executorService.submit(() -> { + try (socket; + InputStream in = socket.getInputStream(); + OutputStream out = new BufferedOutputStream(socket.getOutputStream()) + ) { + long start = System.nanoTime(); + Request request = RequestParser.parse(in); + if (request == null) return; + + Response response = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + RequestHandler handler = globalRouter.route(request); + + try { + Object result = handler.handle(request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } catch (Exception ex) { + Object result = globalRouter.resolveExceptionHandler(request).handle(ex, request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } + + out.write(HTTP_1_1); + writeStatusPhrase(out, response.getStatusCode()); + out.write(CRLF); + out.write(CONTENT_TYPE); + out.write(response.getContentType()); + out.write(CRLF); + out.write(CONTENT_LENGTH); + writeInt(out, response.getBody() != null ? response.getBody().length : 0); + out.write(CRLF); + out.write(CONNECTION_CLOSE); + out.write(CRLF); + if (response.getBody() != null) out.write(response.getBody()); + out.flush(); + log.info("{} ms: {}", (System.nanoTime() - start) / 1_000_000.0, request.getRequestLine()); + + } catch (IOException e) { + log.error("I/O error handling request", e); + } + }); + } + + private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException { + byte[] phrase = HttpStatus.bytesForCode(statusCode); + if (phrase != null) { + out.write(phrase); + } else { + writeInt(out, statusCode); + out.write(UNKNOWN_STATUS_SUFFIX); + } + } + + + private static void writeInt(OutputStream out, int value) throws IOException { + if (value == 0) { out.write(DIGITS[0]); return; } + if (value < 0) { out.write('-'); value = -value; } + int divisor = 1; + while (value / divisor >= 10) divisor *= 10; + while (divisor > 0) { out.write(DIGITS[(value / divisor) % 10]); divisor /= 10; } + } +} diff --git a/src/main/java/dev/relism/HttpServerConfiguration.java b/src/main/java/dev/relism/HttpServerConfiguration.java new file mode 100644 index 0000000..3a2d9f8 --- /dev/null +++ b/src/main/java/dev/relism/HttpServerConfiguration.java @@ -0,0 +1,11 @@ +package dev.relism; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class HttpServerConfiguration { + private int port; + private String host; +} diff --git a/src/main/java/dev/relism/Main.java b/src/main/java/dev/relism/Main.java new file mode 100644 index 0000000..b236418 --- /dev/null +++ b/src/main/java/dev/relism/Main.java @@ -0,0 +1,41 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.*; +import dev.relism.routing.Route; +import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl; + +import java.io.IOException; + +public class Main { + + /* + @Route(method = "GET", path = "/profile") + public static class ProfileHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return "Class based handler: User profile info under /api/profile"; + } + } + */ + + public static void main(String[] args) throws IOException { + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(8080) + .host("localhost") + .build(); + + HttpServer server = new HttpServer(config); + + FastPathRouterImpl apiRouter = new FastPathRouterImpl(); + server.mount("/api", apiRouter); + + // apiRouter.register(new ProfileHandler()); + + server.get("/headers", (req, res) -> { + throw new RuntimeException(req.getQueryParam("test")); + }); + + server.start().thenRun(() -> System.out.println("Flash running on :" + config.getPort())); + } +} diff --git a/src/main/java/dev/relism/RequestParser.java b/src/main/java/dev/relism/RequestParser.java new file mode 100644 index 0000000..c32dce3 --- /dev/null +++ b/src/main/java/dev/relism/RequestParser.java @@ -0,0 +1,143 @@ +package dev.relism; + +import dev.relism.models.HeaderMap; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; + +/** + * 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. + */ +@Slf4j +public class RequestParser { + private static final int MAX_HEADER_SIZE = 8192; + + public static Request parse(InputStream in) throws IOException { + byte[] buffer = new byte[MAX_HEADER_SIZE]; + + // 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. + int totalRead = 0; + int headerEndIdx = -1; + while (totalRead < buffer.length) { + int n = in.read(buffer, totalRead, buffer.length - totalRead); + if (n <= 0) break; + int prevTotal = totalRead; + totalRead += n; + headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead); + if (headerEndIdx != -1) break; + } + if (totalRead <= 0) return null; + if (headerEndIdx == -1) { + throw new IOException("Headers too large: buffer exhausted without finding \\r\\n\\r\\n"); + } + + // Timer starts here — after I/O, measuring only CPU parse time + long start = System.nanoTime(); + + // 2. Scan Request Line: METHOD PATH PROTOCOL + int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' '); + if (methodEnd == -1) throw new IOException("Invalid request line (method)"); + + HttpMethod method = HttpMethod.fromBytes(buffer, 0, methodEnd); + if (method == null) throw new IOException("Unsupported HTTP method"); + + int pathStart = methodEnd + 1; + 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); + FastPathViews.RequestByteView queryView = queryMark != -1 + ? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1) + : null; + + int protocolStart = pathEnd + 1; + int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r'); + if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)"); + + 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; + + while (current < headerEndIdx) { + int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r'); + if (lineEnd == -1 || lineEnd == current) break; + + int colon = find(buffer, current, lineEnd, (byte) ':'); + if (colon != -1) { + int valueStart = colon + 1; + while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + + 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 + } + + long elapsed = System.nanoTime() - start; + log.debug("Request parsed in {}ns: {} {} {}", elapsed, method, pathView, protocolView); + + // 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( + new RequestLine(method, pathView, queryView, protocolView, headerMap), + in, contentLength, buffer, bodyStart, preBufLen); + } + + private static int findEndOfHeader(byte[] buf, int from, int len) { + for (int i = from; i <= len - 4; i++) { + if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') { + return i; + } + } + return -1; + } + + private static int find(byte[] buf, int start, int end, byte target) { + for (int i = start; i < end; i++) { + if (buf[i] == target) return i; + } + return -1; + } + + private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) { + int len = end - start; + if (len != target.length()) return false; + for (int i = 0; i < len; i++) { + byte b = buf[start + i]; + if (b >= 'A' && b <= 'Z') b += 32; + if (b != (byte) target.charAt(i)) return false; + } + return true; + } + + private static int parseInt(byte[] buf, int start, int end) { + int value = 0; + for (int i = start; i < end; i++) { + byte c = buf[i]; + if (c >= '0' && c <= '9') value = value * 10 + (c - '0'); + } + return value; + } +} diff --git a/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java b/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java new file mode 100644 index 0000000..182898d --- /dev/null +++ b/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java @@ -0,0 +1,7 @@ +package dev.relism.exceptions; + +public class DuplicateNamespaceException extends RuntimeException { + public DuplicateNamespaceException(String namespace) { + super("Router with namespace '" + namespace + "' is already registered."); + } +} diff --git a/src/main/java/dev/relism/http/ContentType.java b/src/main/java/dev/relism/http/ContentType.java new file mode 100644 index 0000000..97cd6ef --- /dev/null +++ b/src/main/java/dev/relism/http/ContentType.java @@ -0,0 +1,63 @@ +package dev.relism.http; + +import lombok.Getter; + +import java.nio.charset.StandardCharsets; + +/** + * Pre-compiled byte representations of common HTTP {@code Content-Type} values. + * {@link #getBytes()} returns the pre-computed array directly — never allocates. + */ +@Getter +public enum ContentType { + + // Text + TEXT_PLAIN ("text/plain"), + TEXT_HTML ("text/html"), + TEXT_CSS ("text/css"), + TEXT_JAVASCRIPT ("text/javascript"), + TEXT_XML ("text/xml"), + TEXT_CSV ("text/csv"), + TEXT_MARKDOWN ("text/markdown"), + TEXT_EVENT_STREAM ("text/event-stream"), + + // Application + JSON ("application/json"), + XML ("application/xml"), + BINARY ("application/octet-stream"), + PDF ("application/pdf"), + ZIP ("application/zip"), + GZIP ("application/gzip"), + FORM_URLENCODED ("application/x-www-form-urlencoded"), + MULTIPART_FORM ("multipart/form-data"), + GRAPHQL ("application/graphql"), + NDJSON ("application/x-ndjson"), + MSGPACK ("application/msgpack"), + CBOR ("application/cbor"), + LD_JSON ("application/ld+json"), + + // Image + IMAGE_PNG ("image/png"), + IMAGE_JPEG ("image/jpeg"), + IMAGE_GIF ("image/gif"), + IMAGE_WEBP ("image/webp"), + IMAGE_SVG ("image/svg+xml"), + IMAGE_ICO ("image/x-icon"), + IMAGE_AVIF ("image/avif"), + + // Font + FONT_WOFF ("font/woff"), + FONT_WOFF2 ("font/woff2"), + + // Audio / Video + AUDIO_MPEG ("audio/mpeg"), + AUDIO_OGG ("audio/ogg"), + VIDEO_MP4 ("video/mp4"), + VIDEO_WEBM ("video/webm"); + + private final byte[] bytes; + + ContentType(String value) { + this.bytes = value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/dev/relism/http/HttpMethod.java b/src/main/java/dev/relism/http/HttpMethod.java new file mode 100644 index 0000000..473987a --- /dev/null +++ b/src/main/java/dev/relism/http/HttpMethod.java @@ -0,0 +1,47 @@ +package dev.relism.http; + +import lombok.Getter; +import java.nio.charset.StandardCharsets; + +@Getter +public enum HttpMethod { + GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT, PURGE; + + private static final HttpMethod[] VALUES = values(); + private final byte[] bytes; + + HttpMethod() { + this.bytes = this.name().getBytes(StandardCharsets.UTF_8); + } + + /** + * Resolves the HttpMethod from a byte buffer segment using O(1) dispatch. + * Extremely fast, zero allocations, branch-prediction friendly. + */ + public static HttpMethod fromBytes(byte[] buf, int off, int len) { + if (len == 0) return null; + + return switch (buf[off]) { + case 'G' -> (len == 3 && buf[off + 1] == 'E' && buf[off + 2] == 'T') ? GET : null; + case 'P' -> { + if (len == 3 && buf[off + 1] == 'U' && buf[off + 2] == 'T') yield PUT; + if (len == 4 && buf[off + 1] == 'O' && buf[off + 2] == 'S' && buf[off + 3] == 'T') yield POST; + if (len == 5 && buf[off + 1] == 'A' && buf[off + 2] == 'T' && buf[off + 3] == 'C' && buf[off + 4] == 'H') yield PATCH; + if (len == 5 && buf[off + 1] == 'U' && buf[off + 2] == 'R' && buf[off + 3] == 'G' && buf[off + 4] == 'E') yield PURGE; + yield null; + } + case 'D' -> (len == 6 && buf[off + 1] == 'E' && buf[off + 2] == 'L' && buf[off + 3] == 'E' && buf[off + 4] == 'T' && buf[off + 5] == 'E') ? DELETE : null; + case 'O' -> (len == 7 && buf[off + 1] == 'P' && buf[off + 2] == 'T' && buf[off + 3] == 'I' && buf[off + 4] == 'O' && buf[off + 5] == 'N' && buf[off + 6] == 'S') ? OPTIONS : null; + case 'H' -> (len == 4 && buf[off + 1] == 'E' && buf[off + 2] == 'A' && buf[off + 3] == 'D') ? HEAD : null; + case 'T' -> (len == 5 && buf[off + 1] == 'R' && buf[off + 2] == 'A' && buf[off + 3] == 'C' && buf[off + 4] == 'E') ? TRACE : null; + case 'C' -> (len == 7 && buf[off + 1] == 'O' && buf[off + 2] == 'N' && buf[off + 3] == 'N' && buf[off + 4] == 'E' && buf[off + 5] == 'C' && buf[off + 6] == 'T') ? CONNECT : null; + default -> null; + }; + } + + //Only to be used for debugging/logging purposes, never in the hot path. + @Override + public String toString() { + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/dev/relism/http/HttpStatus.java b/src/main/java/dev/relism/http/HttpStatus.java new file mode 100644 index 0000000..5e6e8e9 --- /dev/null +++ b/src/main/java/dev/relism/http/HttpStatus.java @@ -0,0 +1,71 @@ +package dev.relism.http; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Pre-compiled byte representations of standard HTTP status lines (e.g. {@code "200 OK"}). + * {@link #bytesForCode(int)} returns the cached array for known codes, {@code null} otherwise. + */ +public enum HttpStatus { + + // 1xx + CONTINUE (100, "Continue"), + SWITCHING_PROTOCOLS (101, "Switching Protocols"), + + // 2xx + OK (200, "OK"), + CREATED (201, "Created"), + ACCEPTED (202, "Accepted"), + NO_CONTENT (204, "No Content"), + PARTIAL_CONTENT (206, "Partial Content"), + + // 3xx + MOVED_PERMANENTLY (301, "Moved Permanently"), + FOUND (302, "Found"), + NOT_MODIFIED (304, "Not Modified"), + TEMPORARY_REDIRECT (307, "Temporary Redirect"), + PERMANENT_REDIRECT (308, "Permanent Redirect"), + + // 4xx + BAD_REQUEST (400, "Bad Request"), + UNAUTHORIZED (401, "Unauthorized"), + FORBIDDEN (403, "Forbidden"), + NOT_FOUND (404, "Not Found"), + METHOD_NOT_ALLOWED (405, "Method Not Allowed"), + NOT_ACCEPTABLE (406, "Not Acceptable"), + CONFLICT (409, "Conflict"), + GONE (410, "Gone"), + LENGTH_REQUIRED (411, "Length Required"), + PAYLOAD_TOO_LARGE (413, "Payload Too Large"), + URI_TOO_LONG (414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"), + UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"), + TOO_MANY_REQUESTS (429, "Too Many Requests"), + + // 5xx + INTERNAL_SERVER_ERROR (500, "Internal Server Error"), + NOT_IMPLEMENTED (501, "Not Implemented"), + BAD_GATEWAY (502, "Bad Gateway"), + SERVICE_UNAVAILABLE (503, "Service Unavailable"), + GATEWAY_TIMEOUT (504, "Gateway Timeout"); + + private final int code; + private final byte[] bytes; + + private static final Map INDEX = new HashMap<>(); + static { + for (HttpStatus s : values()) INDEX.put(s.code, s.bytes); + } + + HttpStatus(int code, String reason) { + this.code = code; + this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + } + + /** Returns pre-compiled {@code "CODE Reason"} bytes for the given code, or {@code null} if unknown. */ + public static byte[] bytesForCode(int code) { + return INDEX.get(code); + } +} diff --git a/src/main/java/dev/relism/models/HeaderMap.java b/src/main/java/dev/relism/models/HeaderMap.java new file mode 100644 index 0000000..7454401 --- /dev/null +++ b/src/main/java/dev/relism/models/HeaderMap.java @@ -0,0 +1,96 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +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. + */ +public class HeaderMap { + private static final int MAX_HEADERS = 32; + + private final byte[] buffer; + private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs + private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs + private int count = 0; + + public HeaderMap(byte[] buffer) { + 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; + keys[count * 2 + 1] = keyLen; + values[count * 2] = valStart; + values[count * 2 + 1] = valLen; + 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. */ + public List getAll(String name) { + List result = null; + for (int i = 0; i < count; i++) { + if (keyMatches(i, name)) { + if (result == null) result = new ArrayList<>(); + result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8)); + } + } + return result != null ? result : List.of(); + } + + /** Lazy: decodes and returns all values as a List. */ + public List getAll() { + List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8)); + } + return result; + } + + /** Zero-copy: returns a ByteView over the raw value bytes without allocating a String. */ + public ByteView getView(String name) { + int idx = indexOf(name); + if (idx < 0) return null; + final int start = values[idx * 2]; + final int len = values[idx * 2 + 1]; + return new ByteView() { + public int length() { return len; } + public byte byteAt(int i) { return buffer[start + i]; } + }; + } + + private int indexOf(String name) { + for (int i = 0; i < count; i++) { + if (keyMatches(i, name)) return i; + } + 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; + for (int j = 0; j < kl; j++) { + byte b = buffer[ks + j]; + if (b >= 'A' && b <= 'Z') b += 32; + char c = name.charAt(j); + if (c >= 'A' && c <= 'Z') c += 32; + if (b != (byte) c) return false; + } + return true; + } +} diff --git a/src/main/java/dev/relism/models/LazyBody.java b/src/main/java/dev/relism/models/LazyBody.java new file mode 100644 index 0000000..8b418e5 --- /dev/null +++ b/src/main/java/dev/relism/models/LazyBody.java @@ -0,0 +1,63 @@ +package dev.relism.models; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; + +/** + * Deferred request body reader. + * + *

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. + */ +final class LazyBody { + private static final byte[] EMPTY = new byte[0]; + + private final InputStream stream; + private final int contentLength; + private final byte[] preBuf; + private final int preBufOff; + private final int preBufLen; + private byte[] resolved; + + LazyBody(InputStream stream, int contentLength, byte[] preBuf, int preBufOff, int preBufLen) { + this.stream = stream; + this.contentLength = contentLength; + this.preBuf = preBuf; + this.preBufOff = preBufOff; + 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; + return lb; + } + + byte[] get() { + if (resolved != null) return resolved; + byte[] buf = new byte[contentLength]; + int copied = Math.min(preBufLen, contentLength); + if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied); + if (copied < contentLength) { + try { + stream.readNBytes(buf, copied, contentLength - copied); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return resolved = buf; + } +} diff --git a/src/main/java/dev/relism/models/PathParams.java b/src/main/java/dev/relism/models/PathParams.java new file mode 100644 index 0000000..367072c --- /dev/null +++ b/src/main/java/dev/relism/models/PathParams.java @@ -0,0 +1,51 @@ +package dev.relism.models; + +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). + */ +public class PathParams { + private final ByteView source; + private final String[] names; + private final int[] starts; + private final int[] lens; + + public PathParams(ByteView source, String[] names, int[] starts, int[] lens) { + this.source = source; + this.names = names; + this.starts = starts; + 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; + byte[] bytes = new byte[lens[i]]; + for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j); + 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; + final int s = starts[i], l = lens[i]; + return new ByteView() { + public int length() { return l; } + public byte byteAt(int idx) { return source.byteAt(s + idx); } + }; + } + + private int indexOf(String name) { + for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i; + return -1; + } +} diff --git a/src/main/java/dev/relism/models/QueryParams.java b/src/main/java/dev/relism/models/QueryParams.java new file mode 100644 index 0000000..2170375 --- /dev/null +++ b/src/main/java/dev/relism/models/QueryParams.java @@ -0,0 +1,100 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; + +import java.nio.charset.StandardCharsets; +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). + */ +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; + + public QueryParams(ByteView raw) { + 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; + int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); + byte[] bytes = new byte[l]; + for (int i = 0; i < l; i++) bytes[i] = raw.byteAt(s + i); + 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; + final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); + return new ByteView() { + public int length() { return l; } + public byte byteAt(int idx) { return raw.byteAt(s + idx); } + }; + } + + /** Lazy — returns all values for {@code name}, or an empty list if absent. */ + public List getAll(String name) { + if (raw == null) return List.of(); + List result = null; + int i = 0, len = raw.length(); + while (i < len) { + int keyStart = i; + while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++; + int keyLen = i - keyStart; + if (i < len && raw.byteAt(i) == '=') { + i++; + int valStart = i; + while (i < len && raw.byteAt(i) != '&') i++; + if (keyMatches(keyStart, keyLen, name)) { + int valLen = i - valStart; + byte[] bytes = new byte[valLen]; + for (int j = 0; j < valLen; j++) bytes[j] = raw.byteAt(valStart + j); + if (result == null) result = new ArrayList<>(); + result.add(new String(bytes, StandardCharsets.UTF_8)); + } + } + if (i < len && raw.byteAt(i) == '&') i++; + } + 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. + */ + private long findFirst(String name) { + if (raw == null) return -1L; + int i = 0, len = raw.length(); + while (i < len) { + int keyStart = i; + while (i < len && raw.byteAt(i) != '=' && raw.byteAt(i) != '&') i++; + int keyLen = i - keyStart; + if (i < len && raw.byteAt(i) == '=') { + i++; + int valStart = i; + while (i < len && raw.byteAt(i) != '&') i++; + if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart); + } + if (i < len && raw.byteAt(i) == '&') i++; + } + return -1L; + } + + private boolean keyMatches(int start, int len, String name) { + if (len != name.length()) return false; + for (int i = 0; i < len; i++) if (raw.byteAt(start + i) != (byte) name.charAt(i)) return false; + return true; + } +} diff --git a/src/main/java/dev/relism/models/Request.java b/src/main/java/dev/relism/models/Request.java new file mode 100644 index 0000000..9f00525 --- /dev/null +++ b/src/main/java/dev/relism/models/Request.java @@ -0,0 +1,89 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.Value; +import lombok.experimental.NonFinal; + +import java.io.InputStream; +import java.util.List; + +@Value +@ToString +public class Request { + RequestLine requestLine; + + @Getter(lombok.AccessLevel.NONE) + @EqualsAndHashCode.Exclude + @ToString.Exclude + LazyBody lazyBody; + + @NonFinal @Setter @Getter PathParams pathParams; + @NonFinal @Setter @Getter QueryParams queryParams; + + private Request(RequestLine requestLine, LazyBody lazyBody) { + this.requestLine = requestLine; + this.lazyBody = lazyBody; + this.pathParams = null; + 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) { + LazyBody lazy = contentLength > 0 + ? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen) + : LazyBody.empty(); + 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 getHeaders(String name) { return requestLine.getHeaders().getAll(name); } + public List getHeaders() { return requestLine.getHeaders().getAll(); } + + // --- 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 getQueryParams(String name) { + return resolveQueryParams().getAll(name); + } + + private QueryParams resolveQueryParams() { + if (queryParams == null) { + ByteView raw = requestLine.getQuery(); + queryParams = raw != null ? new QueryParams(raw) : QueryParams.EMPTY; + } + return queryParams; + } +} diff --git a/src/main/java/dev/relism/models/RequestHandler.java b/src/main/java/dev/relism/models/RequestHandler.java new file mode 100644 index 0000000..f4f546e --- /dev/null +++ b/src/main/java/dev/relism/models/RequestHandler.java @@ -0,0 +1,17 @@ +package dev.relism.models; + +/** + * Base class for class-based route handlers. + * + *

Annotate the subclass with {@link dev.relism.routing.Route @Route} and register it + * via {@link dev.relism.routing.AbstractRouter#register}. For one-off routes, prefer the + * lambda DSL ({@code server.get(path, handler)}) which wraps a {@link SimpleHandler} internally. + */ +public abstract class RequestHandler { + /** + * Handles an incoming request. The return value determines the response body: + * return a {@link Response} to replace the whole response, any other non-null value + * to set it as the body, or {@code null} to leave the response as-is. + */ + public abstract Object handle(Request request, Response response); +} \ No newline at end of file diff --git a/src/main/java/dev/relism/models/RequestLine.java b/src/main/java/dev/relism/models/RequestLine.java new file mode 100644 index 0000000..93cf4f9 --- /dev/null +++ b/src/main/java/dev/relism/models/RequestLine.java @@ -0,0 +1,17 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import lombok.ToString; +import lombok.Value; + +@ToString +@Value +public class RequestLine { + HttpMethod method; + ByteView path; + /** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */ + ByteView query; + ByteView protocol; + HeaderMap headers; +} diff --git a/src/main/java/dev/relism/models/Response.java b/src/main/java/dev/relism/models/Response.java new file mode 100644 index 0000000..c121104 --- /dev/null +++ b/src/main/java/dev/relism/models/Response.java @@ -0,0 +1,45 @@ +package dev.relism.models; + +import dev.relism.http.ContentType; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.nio.charset.StandardCharsets; + +@Getter +@ToString +public class Response { + @Setter private int statusCode; + private byte[] body; + private byte[] contentType; + + public Response(int statusCode, byte[] body, ContentType contentType) { + this.statusCode = statusCode; + this.body = body; + this.contentType = contentType.getBytes(); + } + + public Response(int statusCode, String text, ContentType contentType) { + this.statusCode = statusCode; + this.body = text.getBytes(StandardCharsets.UTF_8); + this.contentType = contentType.getBytes(); + } + + public void setContentType(ContentType contentType) { + this.contentType = contentType.getBytes(); + } + + public void setContentType(String contentType) { + this.contentType = contentType.getBytes(StandardCharsets.UTF_8); + } + + public Response setBody(Object body) { + if (body instanceof byte[] bytes) { + this.body = bytes; + } else if (body != null) { + this.body = body.toString().getBytes(StandardCharsets.UTF_8); + } + return this; + } +} diff --git a/src/main/java/dev/relism/models/SimpleHandler.java b/src/main/java/dev/relism/models/SimpleHandler.java new file mode 100644 index 0000000..49e0103 --- /dev/null +++ b/src/main/java/dev/relism/models/SimpleHandler.java @@ -0,0 +1,26 @@ +package dev.relism.models; + +import lombok.RequiredArgsConstructor; + +/** + * A concrete implementation of {@link RequestHandler} that delegates to a functional interface. + * This allows the use of lambdas while keeping the base {@link RequestHandler} as an abstract class. + */ +@RequiredArgsConstructor +public class SimpleHandler extends RequestHandler { + + private final FunctionalHandler delegate; + + @Override + public Object handle(Request request, Response response) { + return delegate.handle(request, response); + } + + /** + * Functional interface for handling requests, used by {@link SimpleHandler}. + */ + @FunctionalInterface + public interface FunctionalHandler { + Object handle(Request request, Response response); + } +} diff --git a/src/main/java/dev/relism/routing/AbstractRouter.java b/src/main/java/dev/relism/routing/AbstractRouter.java new file mode 100644 index 0000000..abf5245 --- /dev/null +++ b/src/main/java/dev/relism/routing/AbstractRouter.java @@ -0,0 +1,133 @@ +package dev.relism.routing; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.ContentType; +import dev.relism.http.HttpMethod; +import dev.relism.models.*; +import dev.relism.template.ErrorPages; + +import lombok.AccessLevel; +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. + * + *

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. + */ +public abstract class AbstractRouter { + + @Getter + protected String namespace = "/"; + + @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); + return ErrorPages.renderNotFound(req); + }); + + protected ExceptionHandler exceptionHandler = (ex, req, res) -> { + res.setStatusCode(500); + res.setContentType(ContentType.TEXT_HTML); + return ErrorPages.renderException(req, ex); + }; + + // Package-private — used by GlobalRouter only + SimpleHandler getNotFoundHandler() { return notFoundHandler; } + ExceptionHandler getExceptionHandler() { return exceptionHandler; } + void setNamespace(String namespace) { + this.namespace = namespace; + 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: + *

+ */ + 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. + */ + public AbstractRouter register(RequestHandler handler) { + Route annotation = handler.getClass().getAnnotation(Route.class); + if (annotation != null) { + addRoute(HttpMethod.valueOf(annotation.method()), annotation.path(), handler); + } + 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)); + } + + @FunctionalInterface + public interface ExceptionHandler { + Object handle(Exception exception, Request request, Response response); + } +} diff --git a/src/main/java/dev/relism/routing/GlobalRouter.java b/src/main/java/dev/relism/routing/GlobalRouter.java new file mode 100644 index 0000000..c06bb07 --- /dev/null +++ b/src/main/java/dev/relism/routing/GlobalRouter.java @@ -0,0 +1,77 @@ +package dev.relism.routing; + +import dev.relism.exceptions.DuplicateNamespaceException; +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Top-level dispatcher owned by {@link dev.relism.HttpServer}. + * + *

Routing order on each request: + *

    + *
  1. 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.
  2. + *
  3. If no sub-router matches, falls through to the internal {@link FastPathRouterImpl}.
  4. + *
+ * + *

Not intended to be instantiated or subclassed directly — use {@link dev.relism.HttpServer}. + */ +public class GlobalRouter extends AbstractRouter { + private final Map subRoutersMap = new HashMap<>(); + private final List sortedSubRouters = new ArrayList<>(); + private final AbstractRouter internalRouter = new FastPathRouterImpl(); + + public void mount(String namespace, AbstractRouter router) { + String sanitized = PathUtils.sanitize(namespace); + if (subRoutersMap.containsKey(sanitized)) throw new DuplicateNamespaceException(sanitized); + + router.setNamespace(sanitized); + subRoutersMap.put(sanitized, router); + sortedSubRouters.add(router); + sortedSubRouters.sort(Comparator.comparingInt((AbstractRouter r) -> r.getNamespaceBytes().length).reversed()); + } + + @Override + public RequestHandler route(Request request) { + ByteView path = request.getRequestLine().getPath(); + for (AbstractRouter sub : sortedSubRouters) { + if (startsWith(path, sub.getNamespaceBytes())) { + RequestHandler h = sub.route(request); + return h != null ? h : sub.getNotFoundHandler(); + } + } + RequestHandler h = internalRouter.route(request); + 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) { + if (startsWith(path, sub.getNamespaceBytes())) return sub.getExceptionHandler(); + } + return exceptionHandler; + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + return internalRouter.addRoute(method, PathUtils.sanitize(path), handler); + } + + private static boolean startsWith(ByteView view, byte[] prefix) { + if (view.length() < prefix.length) return false; + for (int i = 0; i < prefix.length; i++) { + if (view.byteAt(i) != prefix[i]) return false; + } + return true; + } +} diff --git a/src/main/java/dev/relism/routing/PathUtils.java b/src/main/java/dev/relism/routing/PathUtils.java new file mode 100644 index 0000000..7968c02 --- /dev/null +++ b/src/main/java/dev/relism/routing/PathUtils.java @@ -0,0 +1,47 @@ +package dev.relism.routing; + +public class PathUtils { + /** + * Sanitizes a path segment to ensure it starts with '/' and has no trailing slash. + * Replaces multiple slashes with a single one. + */ + public static String sanitize(String path) { + if (path == null || path.isBlank() || path.trim().equals("/")) { + return "/"; + } + + String sanitized = path.trim().replaceAll("/{2,}", "/"); + + if (!sanitized.startsWith("/")) { + sanitized = "/" + sanitized; + } + + if (sanitized.length() > 1 && sanitized.endsWith("/")) { + sanitized = sanitized.substring(0, sanitized.length() - 1); + } + + return sanitized; + } + + /** + * Joins two path segments and ensures the result is sanitized. + * Prevents "double namespace" if the path already starts with the base. + */ + public static String join(String base, String path) { + String sBase = sanitize(base); + String sPath = sanitize(path); + + if (sBase.equals("/")) return sPath; + if (sPath.equals("/") || sPath.isEmpty()) return sBase; + + // If sPath already starts with sBase, don't prepend it again + // Example: base="/api", path="/api/users" -> "/api/users" + if (sPath.startsWith(sBase)) { + return sPath; + } + + // Otherwise, prepend base to path + String joined = sBase + (sPath.startsWith("/") ? sPath : "/" + sPath); + return sanitize(joined); + } +} diff --git a/src/main/java/dev/relism/routing/Route.java b/src/main/java/dev/relism/routing/Route.java new file mode 100644 index 0000000..ba34b82 --- /dev/null +++ b/src/main/java/dev/relism/routing/Route.java @@ -0,0 +1,22 @@ +package dev.relism.routing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the HTTP method and path for a class-based {@link dev.relism.models.RequestHandler}. + * + *

The path is relative to the namespace of the router the handler is registered on. + * For example, registering a handler with {@code path = "/profile"} on a router mounted + * at {@code /api} results in the effective route {@code /api/profile}. + * + *

Used by {@link dev.relism.routing.AbstractRouter#register}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Route { + String method() default "GET"; + String path(); +} diff --git a/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java new file mode 100644 index 0000000..14a92ea --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java @@ -0,0 +1,114 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.dsl.StringRouteParser; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.routing.AbstractRouter; +import dev.relism.routing.PathUtils; + +/** + * Router implementation backed by the {@code fpr-core} byte-level state machine. + * + *

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. + */ +public class FastPathRouterImpl extends AbstractRouter { + private final RouterBuilder builder = new RouterBuilder<>(); + private volatile FastPathRouter 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> RESULT_HOLDER = + ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); + private static final ThreadLocal COMBINED_VIEW_HOLDER = + ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new); + + public static MatchResult getResult() { + return RESULT_HOLDER.get(); + } + + public static FastPathViews.MethodPathByteView getCombinedView() { + return COMBINED_VIEW_HOLDER.get(); + } + } + + @Override + 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 + return this; + } + + @Override + public RequestHandler route(Request request) { + ensureCompiled(); + + MatchResult result = FastPathRouterContext.getResult(); + result.reset(); + + HttpMethod method = request.getRequestLine().getMethod(); + ByteView pathView = request.getRequestLine().getPath(); + FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView(); + combinedView.reset(method.getBytes(), pathView); + + int labelId = router.match(combinedView, result); + + if (labelId == FastPathRouter.NO_MATCH) { + return null; + } + + // CAPTURE PARAMETERS + int count = result.paramCount(); + if (count > 0) { + int methodLen = method.getBytes().length; + String[] all = cachedParamNames; + String[] names = new String[count]; + int[] starts = new int[count]; + int[] lens = new int[count]; + + for (int i = 0; i < count; i++) { + names[i] = all[result.keyIdAt(i)]; + starts[i] = result.startAt(i) - methodLen; + lens[i] = result.lenAt(i); + } + setPathParams(request, names, pathView, starts, lens); + } + + 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) { + if (router == null) { + cachedParamNames = builder.paramNames(); // written before volatile router + router = builder.compile(); // volatile write: establishes happens-before + } + } + } + } +} diff --git a/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java new file mode 100644 index 0000000..ebedd6b --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java @@ -0,0 +1,120 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.fpr.core.ByteView; +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. + */ +@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; + private final int length; + + public RequestByteView(byte[] buffer, int start, int length) { + this.buffer = buffer; + this.start = start; + this.length = length; + } + + @Override + public int length() { + return length; + } + + @Override + public byte byteAt(int index) { + if (index < 0 || index >= length) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length); + } + return buffer[start + index]; + } + + @Override + public String toString() { // CHANGED: decode slice directly; avoids full-buffer String copy + 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. + * + *

Mutable by design — reused across requests via {@code ThreadLocal}. + * Call {@link #reset} before each 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; + this.totalLength = method.length + path.length(); + } + + @Override + public int length() { + return totalLength; + } + + @Override + public byte byteAt(int index) { + return index < method.length ? method[index] : path.byteAt(index - method.length); + } + } + + /** + * ByteView implementation for raw byte arrays, typically from a socket. + */ + public static class SocketByteView implements ByteView { + private final byte[] data; + + public SocketByteView(byte[] data) { + this.data = data; + } + + @Override + public int length() { + return data.length; + } + + @Override + public byte byteAt(int index) { + return data[index]; + } + } + + /** + * ByteView implementation for Java Strings. + */ + public static class StringByteView implements ByteView { + private final byte[] bytes; + + public StringByteView(String str) { + this.bytes = str.getBytes(StandardCharsets.UTF_8); + } + + @Override + public int length() { + return bytes.length; + } + + @Override + public byte byteAt(int index) { + return bytes[index]; + } + } +} diff --git a/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java b/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java new file mode 100644 index 0000000..da29a87 --- /dev/null +++ b/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java @@ -0,0 +1,5 @@ +package dev.relism.routing.routers.radix; + +public class RadixPathRouterImpl { + +} diff --git a/src/main/java/dev/relism/template/ByteTemplate.java b/src/main/java/dev/relism/template/ByteTemplate.java new file mode 100644 index 0000000..1d77b46 --- /dev/null +++ b/src/main/java/dev/relism/template/ByteTemplate.java @@ -0,0 +1,75 @@ +package dev.relism.template; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Precompiled, allocation-minimal byte template. + *

+ * Placeholders of the form {@code {{name}}} are detected once at construction. + * Each {@link #render} call makes exactly one allocation: the output byte[]. + *

+ * Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n] + */ +public final class ByteTemplate { + + private final byte[][] segments; // literal byte segments + private final String[] slots; // placeholder names in order + private final int staticLength; // sum of all segment lengths (precomputed) + + public ByteTemplate(String source) { + List segs = new ArrayList<>(); + List slts = new ArrayList<>(); + int start = 0, i = 0; + while (i < source.length()) { + if (source.charAt(i) == '{' && i + 1 < source.length() && source.charAt(i + 1) == '{') { + int end = source.indexOf("}}", i + 2); + if (end < 0) break; + segs.add(source.substring(start, i).getBytes(StandardCharsets.UTF_8)); + slts.add(source.substring(i + 2, end)); + start = end + 2; + i = end + 2; + } else { + i++; + } + } + segs.add(source.substring(start).getBytes(StandardCharsets.UTF_8)); + segments = segs.toArray(new byte[0][]); + slots = slts.toArray(new String[0]); + int sl = 0; + for (byte[] s : segments) sl += s.length; + staticLength = sl; + } + + /** + * Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …} + * Unmatched slots are rendered as empty. + */ + public byte[] render(String... kvPairs) { + byte[][] values = new byte[slots.length][]; + for (int i = 0; i + 1 < kvPairs.length; i += 2) { + String key = kvPairs[i]; + byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8); + for (int j = 0; j < slots.length; j++) { + if (slots[j].equals(key)) { values[j] = val; } + } + } + + int len = staticLength; + for (byte[] v : values) if (v != null) len += v.length; + + byte[] out = new byte[len]; + int pos = 0; + for (int i = 0; i < slots.length; i++) { + System.arraycopy(segments[i], 0, out, pos, segments[i].length); + pos += segments[i].length; + if (values[i] != null) { + System.arraycopy(values[i], 0, out, pos, values[i].length); + pos += values[i].length; + } + } + System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length); + return out; + } +} diff --git a/src/main/java/dev/relism/template/ErrorPages.java b/src/main/java/dev/relism/template/ErrorPages.java new file mode 100644 index 0000000..62c158a --- /dev/null +++ b/src/main/java/dev/relism/template/ErrorPages.java @@ -0,0 +1,87 @@ +package dev.relism.template; + +import dev.relism.Flash; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import lombok.NoArgsConstructor; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +/** + * Precompiled error page templates. + *

+ * Static placeholders ({@code logo_img}, {@code version}) are baked in once at + * class init. Dynamic placeholders (method, path, exception …) are filled per + * call with a single allocation via {@link ByteTemplate#render}. + */ +@NoArgsConstructor +public final class ErrorPages { + + private static final ByteTemplate TEMPLATE_404; + private static final ByteTemplate TEMPLATE_500; + + static { + String logo = loadLogoImg(); + TEMPLATE_404 = bake("assets/html/default_404.html", logo); + TEMPLATE_500 = bake("assets/html/default_exception.html", logo); + } + + public static byte[] renderNotFound(Request request) { + RequestLine rl = request.getRequestLine(); + return TEMPLATE_404.render( + "method", rl.getMethod().toString(), + "path", rl.getPath().toString(), + "protocol", rl.getProtocol().toString(), + "timestamp", Instant.now().toString() + ); + } + + public static byte[] renderException(Request request, Exception ex) { + RequestLine rl = request.getRequestLine(); + StringWriter sw = new StringWriter(); + ex.printStackTrace(new PrintWriter(sw)); + return TEMPLATE_500.render( + "method", rl.getMethod().toString(), + "path", rl.getPath().toString(), + "protocol", rl.getProtocol().toString(), + "exception_type", ex.getClass().getName(), + "exception_message", ex.getMessage() != null ? ex.getMessage() : "", + "stacktrace", sw.toString(), + "timestamp", Instant.now().toString() + ); + } + + // --- init helpers --- + + private static ByteTemplate bake(String resource, String logoHtml) { + String raw = load(resource) + .replace("{{logo_img}}", logoHtml) + .replace("{{version}}", Flash.VERSION); + return new ByteTemplate(raw); + } + + private static String load(String resource) { + try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream(resource)) { + if (is == null) throw new RuntimeException("Missing resource: " + resource); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to load: " + resource, e); + } + } + + private static String loadLogoImg() { + try (InputStream is = ErrorPages.class.getClassLoader().getResourceAsStream("assets/logo.png")) { + if (is == null) return ""; + String b64 = Base64.getEncoder().encodeToString(is.readAllBytes()); + return "\"Flash\""; + } catch (IOException e) { + return ""; + } + } +} diff --git a/src/main/resources/assets/html/default_404.html b/src/main/resources/assets/html/default_404.html new file mode 100644 index 0000000..ba88da6 --- /dev/null +++ b/src/main/resources/assets/html/default_404.html @@ -0,0 +1,145 @@ + + + + + + + 404 — Flash + + + + +

+
+
404
+
+

{{method}} {{path}}

+

No route matched this request.

+

{{protocol}}

+
+
+ + + + + diff --git a/src/main/resources/assets/html/default_exception.html b/src/main/resources/assets/html/default_exception.html new file mode 100644 index 0000000..2671a2c --- /dev/null +++ b/src/main/resources/assets/html/default_exception.html @@ -0,0 +1,171 @@ + + + + + + + 500 — Flash + + + + +
+
+
500
+
+

{{method}} {{path}}

+

{{exception_type}}: {{exception_message}}

+
{{stacktrace}}
+

{{protocol}}

+
+
+ + + + + diff --git a/src/main/resources/assets/logo.png b/src/main/resources/assets/logo.png new file mode 100644 index 0000000..f66eadd Binary files /dev/null and b/src/main/resources/assets/logo.png differ diff --git a/src/test/java/dev/relism/HttpServerConcurrencyTest.java b/src/test/java/dev/relism/HttpServerConcurrencyTest.java new file mode 100644 index 0000000..c503215 --- /dev/null +++ b/src/test/java/dev/relism/HttpServerConcurrencyTest.java @@ -0,0 +1,230 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpServerConcurrencyTest { + + private HttpServer server; + private int port; + private HttpClient httpClient; + + @BeforeEach + void setUp() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(port) + .host("127.0.0.1") + .build(); + + server = new HttpServer(config); + server.get("/ping", (req, res) -> "pong"); + server.post("/echo", (req, res) -> { + byte[] body = req.getBody(); + return new Response(200, body, ContentType.TEXT_PLAIN); + }); + + server.start().get(5, TimeUnit.SECONDS); + + httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @AfterEach + void tearDown() { + if (server != null) + server.stop(); + } + + // --- helpers --- + + private HttpResponse get(int targetPort, String path) throws Exception { + return httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + targetPort + path)) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse get(String path) throws Exception { + return get(port, path); + } + + private HttpResponse post(String path, String body) throws Exception { + return httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + path)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + // --- concurrency --- + + @Test + void concurrent_getRequests_allReturn200() throws Exception { + int count = 20; + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + List responses = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < count; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get("/ping"); + String summary = res.statusCode() + "|" + res.body(); + responses.add(summary); + if (res.statusCode() == 200 && "pong".equals(res.body())) { + successes.incrementAndGet(); + } + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(count, successes.get(), () -> "Responses: " + responses); + } + + @Test + void concurrent_mixedRoutes_allReturn200() throws Exception { + int perMethod = 10; + int total = perMethod * 2; + ExecutorService pool = Executors.newFixedThreadPool(total); + CountDownLatch ready = new CountDownLatch(total); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger getSuccesses = new AtomicInteger(); + AtomicInteger postSuccesses = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < perMethod; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get("/ping"); + if (res.statusCode() == 200 && "pong".equals(res.body())) + getSuccesses.incrementAndGet(); + } catch (Exception e) { + errors.add(e); + } + }); + } + for (int i = 0; i < perMethod; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = post("/echo", "hello"); + if (res.statusCode() == 200 && "hello".equals(res.body())) + postSuccesses.incrementAndGet(); + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(perMethod, getSuccesses.get()); + assertEquals(perMethod, postSuccesses.get()); + } + + /** + * Races the router's lazy-compile step: a fresh server with 10 registered routes + * is hit by threads simultaneously before any request has been processed, + * causing multiple threads to compete on the first compilation. + */ + @Test + void concurrent_lazyCompile_noRaceCondition() throws Exception { + int freshPort; + try (ServerSocket s = new ServerSocket(0)) { + freshPort = s.getLocalPort(); + } + + HttpServer freshServer = new HttpServer(HttpServerConfiguration.builder() + .port(freshPort) + .host("127.0.0.1") + .build()); + + for (int i = 0; i < 10; i++) { + final int idx = i; + freshServer.get("/route" + idx, (req, res) -> "handler" + idx); + } + + freshServer.start().get(5, TimeUnit.SECONDS); + + int count = 20; + ExecutorService pool = Executors.newFixedThreadPool(count); + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger successes = new AtomicInteger(); + List errors = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < count; i++) { + final int routeIdx = i % 10; + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpResponse res = get(freshPort, "/route" + routeIdx); + if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body())) { + successes.incrementAndGet(); + } + } catch (Exception e) { + errors.add(e); + } + }); + } + + ready.await(); + start.countDown(); + pool.shutdown(); + + try { + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors); + assertEquals(count, successes.get()); + } finally { + freshServer.stop(); + } + } +} diff --git a/src/test/java/dev/relism/HttpServerTest.java b/src/test/java/dev/relism/HttpServerTest.java new file mode 100644 index 0000000..c5c8d97 --- /dev/null +++ b/src/test/java/dev/relism/HttpServerTest.java @@ -0,0 +1,147 @@ +package dev.relism; + +import dev.relism.http.ContentType; +import dev.relism.models.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpServerTest { + + private HttpServer server; + private int port; + + @BeforeEach + void setUp() throws Exception { + // Find a free ephemeral port + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + + HttpServerConfiguration config = HttpServerConfiguration.builder() + .port(port) + .host("127.0.0.1") + .build(); + + server = new HttpServer(config); + + // Setup some routes + server.get("/api/ping", (req, res) -> "pong"); + + server.post("/api/echo", (req, res) -> { + byte[] body = req.getBody(); + return new Response(201, body, ContentType.TEXT_PLAIN); // echo body & change status + }); + + server.get("/api/crash", (req, res) -> { + throw new RuntimeException("Simulated Crash"); + }); + + server.start().get(5, TimeUnit.SECONDS); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(); + } + } + + // --- raw socket helper --- + + private String sendRawRequest(String rawHttp) throws Exception { + 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); + } + return baos.toString(StandardCharsets.UTF_8); + } + } + + // --- E2E Tests --- + + @Test + void testGet_pingRoute_returns200AndStringBody() throws Exception { + String req = "GET /api/ping HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 200 OK")); + assertTrue(res.contains("Content-Length: 4")); // "pong" + assertTrue(res.endsWith("pong")); + } + + @Test + void testPost_echoRoute_returns201AndEchoesBody() throws Exception { + String body = "Hello, Flash!"; + String req = "POST /api/echo HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Length: " + body.length() + "\r\n" + + "\r\n" + + body; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 201 Created")); + assertTrue(res.contains("Content-Length: " + body.length())); + assertTrue(res.endsWith(body)); + } + + @Test + void testNotFound_returns404Html() throws Exception { + String req = "GET /api/unknown HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 404 Not Found")); + assertTrue(res.contains("404")); + assertTrue(res.contains("No route matched this request")); + } + + @Test + void testException_returns500Html() throws Exception { + String req = "GET /api/crash HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error")); + assertTrue(res.contains("500")); + assertTrue(res.contains("Simulated Crash")); + } + + @Test + void testRoot_returns404_noExceptionTossed() throws Exception { + String req = "GET / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n"; + + String res = sendRawRequest(req); + + assertTrue(res.startsWith("HTTP/1.1 404 Not Found")); + assertTrue(res.contains("No route matched this request.")); + } +} diff --git a/src/test/java/dev/relism/RequestParserTest.java b/src/test/java/dev/relism/RequestParserTest.java new file mode 100644 index 0000000..846221e --- /dev/null +++ b/src/test/java/dev/relism/RequestParserTest.java @@ -0,0 +1,127 @@ +package dev.relism; + +import dev.relism.models.Request; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestParserTest { + + // --- helpers --- + + 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)); + } + + private static String req(String requestLine, String... headers) { + StringBuilder sb = new StringBuilder(requestLine).append("\n"); + for (String h : headers) sb.append(h).append("\n"); + return sb.append("\n").toString(); + } + + // --- request line --- + + @Test + void path_withoutQueryString() throws IOException { + Request r = parse(req("GET /hello HTTP/1.1", "Host: localhost")); + assertEquals("/hello", r.getRequestLine().getPath().toString()); + assertNull(r.getRequestLine().getQuery()); + } + + @Test + void path_splitsAtQuestionMark() throws IOException { + Request r = parse(req("GET /hello?foo=bar&baz=qux HTTP/1.1", "Host: localhost")); + assertEquals("/hello", r.getRequestLine().getPath().toString()); + assertEquals("foo=bar&baz=qux", r.getRequestLine().getQuery().toString()); + } + + @Test + void queryParam_resolvedFromPath() throws IOException { + Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost")); + assertEquals("flash", r.getQueryParam("q")); + assertEquals("2", r.getQueryParam("page")); + } + + // --- headers --- + + @Test + void headers_parsed() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json")); + assertEquals("example.com", r.getHeader("Host")); + assertEquals("application/json", r.getHeader("Accept")); + } + + @Test + void headers_caseInsensitive() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain")); + assertEquals("text/plain", r.getHeader("content-type")); + assertEquals("text/plain", r.getHeader("CONTENT-TYPE")); + } + + // --- body --- + + @Test + 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))); + assertNotNull(r); + assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8)); + } + + @Test + void body_emptyWhenNoContentLength() throws IOException { + Request r = parse(req("GET / HTTP/1.1", "Host: localhost")); + assertEquals(0, r.getBody().length); + } + + // --- edge cases / robustness --- + + @Test + void emptyInputStream_returnsNull() throws IOException { + assertNull(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))); + } + + @Test + void unknownHttpMethod_throwsIOException() { + assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost"))); + } + + @Test + void requestLine_noProtocol_throwsIOException() { + // No space after path — parser cannot find protocol boundary + assertThrows(IOException.class, () -> parse(req("GET /noproto"))); + } + + @Test + void headersOverBufferSize_throwsIOException() { + // 9 KB of data with no \r\n\r\n exhausts the 8 KB buffer + byte[] giant = new byte[9000]; + Arrays.fill(giant, (byte) 'A'); + assertThrows(IOException.class, () -> RequestParser.parse(new ByteArrayInputStream(giant))); + } + + @Test + void contentLength_largerThanBody_readsPartial() throws IOException { + // 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))); + assertNotNull(r); + assertEquals(50, r.getBody().length); + assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/dev/relism/http/ContentTypeTest.java b/src/test/java/dev/relism/http/ContentTypeTest.java new file mode 100644 index 0000000..fe5620a --- /dev/null +++ b/src/test/java/dev/relism/http/ContentTypeTest.java @@ -0,0 +1,27 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ContentTypeTest { + + // --- getBytes --- + + @Test + void getBytes_validType() { + assertArrayEquals("text/plain".getBytes(StandardCharsets.UTF_8), ContentType.TEXT_PLAIN.getBytes()); + assertArrayEquals("application/json".getBytes(StandardCharsets.UTF_8), ContentType.JSON.getBytes()); + assertArrayEquals("image/png".getBytes(StandardCharsets.UTF_8), ContentType.IMAGE_PNG.getBytes()); + } + + @Test + void getBytes_instancesAreNotNull() { + for (ContentType ct : ContentType.values()) { + assertNotNull(ct.getBytes()); + assertTrue(ct.getBytes().length > 0); + } + } +} diff --git a/src/test/java/dev/relism/http/HttpMethodTest.java b/src/test/java/dev/relism/http/HttpMethodTest.java new file mode 100644 index 0000000..c65b891 --- /dev/null +++ b/src/test/java/dev/relism/http/HttpMethodTest.java @@ -0,0 +1,69 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpMethodTest { + + // --- helpers --- + + private static HttpMethod parse(String raw) { + byte[] b = raw.getBytes(StandardCharsets.UTF_8); + return HttpMethod.fromBytes(b, 0, b.length); + } + + private static HttpMethod parse(String raw, int off, int len) { + byte[] b = raw.getBytes(StandardCharsets.UTF_8); + return HttpMethod.fromBytes(b, off, len); + } + + // --- fromBytes --- + + @Test + void fromBytes_validMethods() { + assertEquals(HttpMethod.GET, parse("GET")); + assertEquals(HttpMethod.POST, parse("POST")); + assertEquals(HttpMethod.PUT, parse("PUT")); + assertEquals(HttpMethod.DELETE, parse("DELETE")); + assertEquals(HttpMethod.PATCH, parse("PATCH")); + assertEquals(HttpMethod.OPTIONS, parse("OPTIONS")); + assertEquals(HttpMethod.HEAD, parse("HEAD")); + assertEquals(HttpMethod.TRACE, parse("TRACE")); + assertEquals(HttpMethod.CONNECT, parse("CONNECT")); + assertEquals(HttpMethod.PURGE, parse("PURGE")); + } + + @Test + void fromBytes_withOffsetAndLength() { + assertEquals(HttpMethod.POST, parse("XXXPOSTYYY", 3, 4)); + assertEquals(HttpMethod.GET, parse(" GET ", 1, 3)); + } + + @Test + void fromBytes_invalidMethods_returnsNull() { + assertNull(parse("INVALID")); + assertNull(parse("GE")); // Too short + assertNull(parse("GETT")); // Too long + assertNull(parse("posT")); // Case sensitive + assertNull(parse("")); // Empty + } + + // --- bytes --- + + @Test + void getBytes_matchesName() { + assertArrayEquals("GET".getBytes(StandardCharsets.UTF_8), HttpMethod.GET.getBytes()); + assertArrayEquals("POST".getBytes(StandardCharsets.UTF_8), HttpMethod.POST.getBytes()); + } + + // --- toString --- + + @Test + void toString_matchesName() { + assertEquals("GET", HttpMethod.GET.toString()); + assertEquals("DELETE", HttpMethod.DELETE.toString()); + } +} diff --git a/src/test/java/dev/relism/http/HttpStatusTest.java b/src/test/java/dev/relism/http/HttpStatusTest.java new file mode 100644 index 0000000..191cf90 --- /dev/null +++ b/src/test/java/dev/relism/http/HttpStatusTest.java @@ -0,0 +1,34 @@ +package dev.relism.http; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpStatusTest { + + // --- bytesForCode --- + + @Test + void bytesForCode_validCodes() { + assertArrayEquals("200 OK".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(200)); + assertArrayEquals("404 Not Found".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(404)); + assertArrayEquals("500 Internal Server Error".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(500)); + } + + @Test + void bytesForCode_unknownCode_returnsNull() { + assertNull(HttpStatus.bytesForCode(999)); + assertNull(HttpStatus.bytesForCode(0)); + assertNull(HttpStatus.bytesForCode(2000)); + } + + @Test + void bytesForCode_allEnumsPresentInIndex() { + // Let's just double check a handful of other representations to ensure full mapping + assertArrayEquals("100 Continue".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(100)); + assertArrayEquals("301 Moved Permanently".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(301)); + assertArrayEquals("400 Bad Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(400)); + } +} diff --git a/src/test/java/dev/relism/models/HeaderMapTest.java b/src/test/java/dev/relism/models/HeaderMapTest.java new file mode 100644 index 0000000..cfd0607 --- /dev/null +++ b/src/test/java/dev/relism/models/HeaderMapTest.java @@ -0,0 +1,105 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class HeaderMapTest { + + // --- helpers --- + + private static HeaderMap parse(String raw, String... headers) { + StringBuilder sb = new StringBuilder(); + for (String h : headers) sb.append(h).append("\r\n"); + byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8); + HeaderMap map = new HeaderMap(buffer); + + int current = 0; + for (int i = 0; i < headers.length; i++) { + int colon = sb.indexOf(":", current); + int lineEnd = sb.indexOf("\r\n", current); + int valueStart = colon + 1; + while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + + map.add(current, colon - current, valueStart, lineEnd - valueStart); + current = lineEnd + 2; + } + return map; + } + + // --- getFirst --- + + @Test + void getFirst_existingHeader() { + HeaderMap map = parse("", "Host: localhost", "Accept: text/plain"); + assertEquals("localhost", map.getFirst("Host")); + assertEquals("text/plain", map.getFirst("Accept")); + } + + @Test + void getFirst_caseInsensitive() { + HeaderMap map = parse("", "ConteNT-tYPe: application/json"); + assertEquals("application/json", map.getFirst("content-type")); + assertEquals("application/json", map.getFirst("CONTENT-TYPE")); + } + + @Test + void getFirst_missingHeader_returnsNull() { + HeaderMap map = parse("", "Host: localhost"); + assertNull(map.getFirst("Accept")); + } + + // --- getAll --- + + @Test + void getAll_multipleValuesByName() { + HeaderMap map = parse("", "Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2"); + assertEquals(List.of("a=1", "b=2"), map.getAll("Cookie")); + } + + @Test + void getAll_missingHeader_returnsEmptyList() { + HeaderMap map = parse("", "Host: localhost"); + assertTrue(map.getAll("Cookie").isEmpty()); + } + + @Test + void getAll_returnsAllHeaders() { + HeaderMap map = parse("", "A: 1", "B: 2"); + assertEquals(List.of("1", "2"), map.getAll()); + } + + // --- getView --- + + @Test + void getView_returnsZeroCopyView() { + HeaderMap map = parse("", "Host: localhost"); + ByteView view = map.getView("Host"); + assertNotNull(view); + assertEquals(9, view.length()); + assertEquals('l', view.byteAt(0)); + assertEquals('t', view.byteAt(8)); + } + + @Test + void getView_missingHeader_returnsNull() { + HeaderMap map = parse("", "Host: localhost"); + assertNull(map.getView("Accept")); + } + + // --- limit --- + + @Test + void maxHeadersLimit() { + byte[] buffer = new byte[100]; + HeaderMap map = new HeaderMap(buffer); + for (int i = 0; i < 32; i++) { + map.add(0, 1, 1, 1); + } + assertThrows(IllegalStateException.class, () -> map.add(0, 1, 1, 1)); + } +} diff --git a/src/test/java/dev/relism/models/PathParamsTest.java b/src/test/java/dev/relism/models/PathParamsTest.java new file mode 100644 index 0000000..f316473 --- /dev/null +++ b/src/test/java/dev/relism/models/PathParamsTest.java @@ -0,0 +1,67 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class PathParamsTest { + + // --- helpers --- + + private static PathParams of(String path, String... paramsPairs) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + ByteView view = new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + + String[] names = new String[paramsPairs.length / 2]; + int[] starts = new int[paramsPairs.length / 2]; + int[] lens = new int[paramsPairs.length / 2]; + + for (int i = 0; i + 1 < paramsPairs.length; i += 2) { + names[i / 2] = paramsPairs[i]; + String val = paramsPairs[i + 1]; + starts[i / 2] = path.indexOf(val); + lens[i / 2] = val.length(); + } + + return new PathParams(view, names, starts, lens); + } + + // --- get --- + + @Test + void get_existingParam() { + PathParams params = of("/users/123/posts/456", "userId", "123", "postId", "456"); + assertEquals("123", params.get("userId")); + assertEquals("456", params.get("postId")); + } + + @Test + void get_missingParam_returnsNull() { + PathParams params = of("/users/123", "userId", "123"); + assertNull(params.get("unknown")); + } + + // --- view --- + + @Test + void view_existingParamZeroCopy() { + PathParams params = of("/users/123", "userId", "123"); + ByteView view = params.view("userId"); + assertNotNull(view); + assertEquals(3, view.length()); + assertEquals('1', view.byteAt(0)); + assertEquals('3', view.byteAt(2)); + } + + @Test + void view_missingParam_returnsNull() { + PathParams params = of("/users/123", "userId", "123"); + assertNull(params.view("unknown")); + } +} diff --git a/src/test/java/dev/relism/models/QueryParamsTest.java b/src/test/java/dev/relism/models/QueryParamsTest.java new file mode 100644 index 0000000..027dd92 --- /dev/null +++ b/src/test/java/dev/relism/models/QueryParamsTest.java @@ -0,0 +1,72 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class QueryParamsTest { + + // --- helpers --- + + private static QueryParams of(String raw) { + byte[] bytes = raw.getBytes(StandardCharsets.UTF_8); + ByteView view = new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + return new QueryParams(view); + } + + // --- get --- + + @Test + void get_singleParam() { + assertEquals("hello", of("name=hello").get("name")); + } + + @Test + void get_firstOfMultiple() { + assertEquals("1", of("a=1&b=2&c=3").get("a")); + assertEquals("2", of("a=1&b=2&c=3").get("b")); + assertEquals("3", of("a=1&b=2&c=3").get("c")); + } + + @Test + void get_missingKey_returnsNull() { + assertNull(of("a=1&b=2").get("z")); + } + + @Test + void get_emptyValue() { + assertEquals("", of("key=").get("key")); + } + + @Test + void get_multiValue_returnsFirst() { + assertEquals("a", of("tag=a&tag=b&tag=c").get("tag")); + } + + // --- getAll --- + + @Test + void getAll_multiValue() { + assertEquals(List.of("a", "b", "c"), of("tag=a&tag=b&tag=c").getAll("tag")); + } + + @Test + void getAll_missingKey_returnsEmpty() { + assertTrue(of("a=1").getAll("z").isEmpty()); + } + + // --- EMPTY --- + + @Test + void empty_returnsNull() { + assertNull(QueryParams.EMPTY.get("anything")); + assertTrue(QueryParams.EMPTY.getAll("anything").isEmpty()); + } +} diff --git a/src/test/java/dev/relism/models/RequestLineTest.java b/src/test/java/dev/relism/models/RequestLineTest.java new file mode 100644 index 0000000..642f471 --- /dev/null +++ b/src/test/java/dev/relism/models/RequestLineTest.java @@ -0,0 +1,42 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestLineTest { + + // --- helpers --- + + private static ByteView viewOf(String s) { + if (s == null) return null; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + // --- initialization --- + + @Test + void constructionAndGetters() { + ByteView path = viewOf("/api"); + ByteView query = viewOf("q=1"); + ByteView proto = viewOf("HTTP/1.1"); + HeaderMap headers = new HeaderMap(new byte[0]); + + RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers); + + assertEquals(HttpMethod.GET, rl.getMethod()); + assertEquals(path, rl.getPath()); + assertEquals(query, rl.getQuery()); + assertEquals(proto, rl.getProtocol()); + assertEquals(headers, rl.getHeaders()); + assertNotNull(rl.toString()); + } +} diff --git a/src/test/java/dev/relism/models/RequestTest.java b/src/test/java/dev/relism/models/RequestTest.java new file mode 100644 index 0000000..993a72a --- /dev/null +++ b/src/test/java/dev/relism/models/RequestTest.java @@ -0,0 +1,88 @@ +package dev.relism.models; + +import dev.relism.fpr.core.ByteView; +import dev.relism.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RequestTest { + + // --- helpers --- + + private static ByteView viewOf(String s) { + if (s == null) return null; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + // --- creation --- + + @Test + void request_creationAndGetters() { + HeaderMap headers = new HeaderMap(new byte[0]); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers); + byte[] body = "body".getBytes(StandardCharsets.UTF_8); + + Request r = new Request(line, body); + + assertEquals(line, r.getRequestLine()); + assertArrayEquals(body, r.getBody()); + assertNotNull(r.toString()); + } + + // --- delegates --- + + @Test + void headers_delegatesToRequestLine() { + byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8); + HeaderMap headers = new HeaderMap(buffer); + headers.add(0, 4, 6, 9); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers); + Request r = new Request(line, new byte[0]); + + assertEquals("localhost", r.getHeader("Host")); + assertEquals(List.of("localhost"), r.getHeaders("Host")); + assertEquals(List.of("localhost"), r.getHeaders()); + } + + // --- pathParams --- + + @Test + void pathParam_lazyGet() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertNull(r.getPathParam("id")); + + r.setPathParams(new PathParams(viewOf("/123"), new String[]{"id"}, new int[]{1}, new int[]{3})); + assertEquals("123", r.getPathParam("id")); + } + + // --- queryParams --- + + @Test + void queryParam_lazyGet_fromQueryString() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertEquals("1", r.getQueryParam("a")); + assertEquals("2", r.getQueryParam("b")); // First value + assertEquals(List.of("2", "3"), r.getQueryParams("b")); + } + + @Test + void queryParam_lazyGet_nullQueryString() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); + Request r = new Request(line, new byte[0]); + + assertNull(r.getQueryParam("a")); + assertTrue(r.getQueryParams("a").isEmpty()); + } +} diff --git a/src/test/java/dev/relism/models/ResponseTest.java b/src/test/java/dev/relism/models/ResponseTest.java new file mode 100644 index 0000000..88ae9de --- /dev/null +++ b/src/test/java/dev/relism/models/ResponseTest.java @@ -0,0 +1,71 @@ +package dev.relism.models; + +import dev.relism.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseTest { + + // --- creation --- + + @Test + void constructor_withByteArray() { + byte[] body = "bytes".getBytes(StandardCharsets.UTF_8); + Response r = new Response(200, body, ContentType.BINARY); + + assertEquals(200, r.getStatusCode()); + assertArrayEquals(body, r.getBody()); + assertArrayEquals("application/octet-stream".getBytes(StandardCharsets.UTF_8), r.getContentType()); + assertNotNull(r.toString()); + } + + @Test + void constructor_withStringText() { + Response r = new Response(404, "Not Found Text", ContentType.TEXT_PLAIN); + + assertEquals(404, r.getStatusCode()); + assertArrayEquals("Not Found Text".getBytes(StandardCharsets.UTF_8), r.getBody()); + assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType()); + } + + // --- setters --- + + @Test + void setContentType_byEnum() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setContentType(ContentType.JSON); + assertArrayEquals(ContentType.JSON.getBytes(), r.getContentType()); + } + + @Test + void setContentType_byString() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setContentType("application/custom"); + assertArrayEquals("application/custom".getBytes(StandardCharsets.UTF_8), r.getContentType()); + } + + @Test + void setBody_withByteArray() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + byte[] newBody = "new".getBytes(StandardCharsets.UTF_8); + r.setBody(newBody); + assertArrayEquals(newBody, r.getBody()); + } + + @Test + void setBody_withObjectConvertedToString() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setBody(12345); // auto boxes to Integer, toString called + assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody()); + } + + @Test + void setStatusCode() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.setStatusCode(201); + assertEquals(201, r.getStatusCode()); + } +} diff --git a/src/test/java/dev/relism/models/SimpleHandlerTest.java b/src/test/java/dev/relism/models/SimpleHandlerTest.java new file mode 100644 index 0000000..84dede8 --- /dev/null +++ b/src/test/java/dev/relism/models/SimpleHandlerTest.java @@ -0,0 +1,19 @@ +package dev.relism.models; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SimpleHandlerTest { + + // --- execution --- + + @Test + void handle_invokesFunctionalHandler() throws Exception { + SimpleHandler.FunctionalHandler func = (req, res) -> "Hello"; + SimpleHandler handler = new SimpleHandler(func); + + Object result = handler.handle(null, null); + assertEquals("Hello", result); + } +} diff --git a/src/test/java/dev/relism/routing/AbstractRouterTest.java b/src/test/java/dev/relism/routing/AbstractRouterTest.java new file mode 100644 index 0000000..0821722 --- /dev/null +++ b/src/test/java/dev/relism/routing/AbstractRouterTest.java @@ -0,0 +1,133 @@ +package dev.relism.routing; + +import dev.relism.http.ContentType; +import dev.relism.http.HttpMethod; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.Response; +import dev.relism.models.SimpleHandler; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class AbstractRouterTest { + + // A dummy router for testing base functionality + static class DummyRouter extends AbstractRouter { + RequestHandler lastAddedHandler; + HttpMethod lastAddedMethod; + String lastAddedPath; + + @Override + public RequestHandler route(Request request) { + return null; // Not testing routing logic here + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + this.lastAddedMethod = method; + this.lastAddedPath = path; + this.lastAddedHandler = handler; + return this; + } + } + + @Route(method = "POST", path = "/profile") + static class ProfileHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + static class UnannotatedHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + + // --- namespace --- + + @Test + void setNamespace_updatesStringAndBytes() { + DummyRouter router = new DummyRouter(); + assertEquals("/", router.getNamespace()); + + router.setNamespace("/api"); + assertEquals("/api", router.getNamespace()); + assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes()); + } + + // --- helpers --- + + @Test + void getPostPutDelete_delegatesToAddRouteWithSanitizedPath() { + DummyRouter router = new DummyRouter(); + SimpleHandler.FunctionalHandler func = (req, res) -> "OK"; + + router.get("users/", func); + assertEquals(HttpMethod.GET, router.lastAddedMethod); + assertEquals("/users", router.lastAddedPath); + assertTrue(router.lastAddedHandler instanceof SimpleHandler); + + router.post("/items", func); + assertEquals(HttpMethod.POST, router.lastAddedMethod); + + router.put("update", func); + assertEquals(HttpMethod.PUT, router.lastAddedMethod); + + router.delete("//delete//", func); + assertEquals(HttpMethod.DELETE, router.lastAddedMethod); + assertEquals("/delete", router.lastAddedPath); + } + + // --- register --- + + @Test + void register_annotatedHandler_addsRoute() { + DummyRouter router = new DummyRouter(); + ProfileHandler handler = new ProfileHandler(); + + router.register(handler); + + assertEquals(HttpMethod.POST, router.lastAddedMethod); + assertEquals("/profile", router.lastAddedPath); + assertEquals(handler, router.lastAddedHandler); + } + + @Test + void register_unannotatedHandler_doesNothing() { + DummyRouter router = new DummyRouter(); + router.register(new UnannotatedHandler()); + assertNull(router.lastAddedMethod); // Nothing added + } + + // --- default handlers --- + + @Test + void defaultNotFoundHandler_returns404Html() throws Exception { + DummyRouter router = new DummyRouter(); + Response res = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + + assertNotNull(router.getNotFoundHandler()); + + SimpleHandler.FunctionalHandler custom = (req, resp) -> "Custom 404"; + router.onNotFound(custom); + + assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res)); + } + + @Test + void defaultExceptionHandler_canBeOverridden() throws Exception { + DummyRouter router = new DummyRouter(); + assertNotNull(router.getExceptionHandler()); + + AbstractRouter.ExceptionHandler custom = (ex, req, res) -> "Caught"; + router.onException(custom); + + assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null)); + } +} diff --git a/src/test/java/dev/relism/routing/GlobalRouterTest.java b/src/test/java/dev/relism/routing/GlobalRouterTest.java new file mode 100644 index 0000000..61c3501 --- /dev/null +++ b/src/test/java/dev/relism/routing/GlobalRouterTest.java @@ -0,0 +1,111 @@ +package dev.relism.routing; + +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.RequestHandler; +import dev.relism.models.RequestLine; +import dev.relism.models.Response; +import dev.relism.models.SimpleHandler; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class GlobalRouterTest { + + // --- mock --- + + static class MockSubRouter extends AbstractRouter { + RequestHandler matchedHandler; + + MockSubRouter(RequestHandler handler) { + this.matchedHandler = handler; + } + + @Override + public RequestHandler route(Request request) { + return matchedHandler; + } + + @Override + protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { + return this; + } + } + + private Request mockRequest(String path) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + RequestLine line = new RequestLine( + HttpMethod.GET, pathView, null, + new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), + new HeaderMap(new byte[0]) + ); + return new Request(line, new byte[0]); + } + + // --- mount & route --- + + @Test + void route_delegatesToSubRouterBasedOnLongestPrefix() { + GlobalRouter global = new GlobalRouter(); + + RequestHandler hApi = new SimpleHandler((req, res) -> "api"); + RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1"); + + global.mount("/api", new MockSubRouter(hApi)); + global.mount("/api/v1", new MockSubRouter(hApiV1)); // longer prefix + + // Path matches /api/v1 -> Should pick hApiV1 because it's longer and sorted first + RequestHandler resolved = global.route(mockRequest("/api/v1/users")); + assertEquals(hApiV1, resolved); + + // Path matches /api but not /api/v1 + RequestHandler resolved2 = global.route(mockRequest("/api/v2/users")); + assertEquals(hApi, resolved2); + } + + @Test + void route_fallsBackToInternalRouter() throws Exception { + GlobalRouter global = new GlobalRouter(); + RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal"); + + global.get("/hello", (req, res) -> "internal"); + // We know it routes to internal. Let's send a request. + RequestHandler resolved = global.route(mockRequest("/hello")); + assertNotNull(resolved); + // It's the compiled FastPathRouter handler, let's verify it works + assertEquals("internal", resolved.handle(null, null)); + } + + @Test + void route_noMatch_returnsNotFoundHandler() { + GlobalRouter global = new GlobalRouter(); + // Nothing registered. Should return the global notFoundHandler. + RequestHandler resolved = global.route(mockRequest("/unknown")); + assertEquals(global.getNotFoundHandler(), resolved); + } + + // --- resolveExceptionHandler --- + + @Test + void resolveExceptionHandler_returnsScopedHandler() { + GlobalRouter global = new GlobalRouter(); + MockSubRouter sub = new MockSubRouter(null); + AbstractRouter.ExceptionHandler customSubHandler = (ex, req, res) -> "sub error"; + sub.onException(customSubHandler); + + global.mount("/api", sub); + + // Under sub-namespace + assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail"))); + + // Outside sub-namespace (global) + assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other"))); + } +} diff --git a/src/test/java/dev/relism/routing/PathUtilsTest.java b/src/test/java/dev/relism/routing/PathUtilsTest.java new file mode 100644 index 0000000..177a7a3 --- /dev/null +++ b/src/test/java/dev/relism/routing/PathUtilsTest.java @@ -0,0 +1,63 @@ +package dev.relism.routing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PathUtilsTest { + + // --- sanitize --- + + @Test + void sanitize_nullOrBlank_returnsRoot() { + assertEquals("/", PathUtils.sanitize(null)); + assertEquals("/", PathUtils.sanitize("")); + assertEquals("/", PathUtils.sanitize(" ")); + assertEquals("/", PathUtils.sanitize("/")); + } + + @Test + void sanitize_trimsWhitespaceAndEnsuresLeadingSlash() { + assertEquals("/users", PathUtils.sanitize(" users ")); + assertEquals("/api", PathUtils.sanitize("api")); + } + + @Test + void sanitize_removesTrailingSlash() { + assertEquals("/users", PathUtils.sanitize("/users/")); + assertEquals("/api/v1", PathUtils.sanitize("/api/v1/")); + } + + @Test + void sanitize_collapsesMultipleSlashes() { + assertEquals("/a/b/c", PathUtils.sanitize("//a///b//c/")); + } + + // --- join --- + + @Test + void join_withRootBase_returnsSanitizedPath() { + assertEquals("/users", PathUtils.join("/", "/users/")); + assertEquals("/users", PathUtils.join("/", "users")); + } + + @Test + void join_withRootPath_returnsSanitizedBase() { + assertEquals("/api", PathUtils.join("/api/", "/")); + assertEquals("/api", PathUtils.join("api", "")); + } + + @Test + void join_preventsDoubleNamespace() { + // Path already starts with base + assertEquals("/api/users", PathUtils.join("/api", "/api/users")); + assertEquals("/api/users", PathUtils.join("/api/", "/api/users/")); + } + + @Test + void join_concatenatesProperly() { + assertEquals("/api/users", PathUtils.join("/api", "users")); + assertEquals("/api/users", PathUtils.join("/api", "/users")); + assertEquals("/api/users", PathUtils.join("api", "users")); + } +} diff --git a/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java new file mode 100644 index 0000000..bd6ee24 --- /dev/null +++ b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java @@ -0,0 +1,76 @@ +package dev.relism.routing.routers.fastpathrouter; + +import dev.relism.http.HttpMethod; +import dev.relism.models.HeaderMap; +import dev.relism.models.Request; +import dev.relism.models.RequestHandler; +import dev.relism.models.RequestLine; +import dev.relism.models.SimpleHandler; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class FastPathRouterImplTest { + + // --- helpers --- + + private Request mockRequest(HttpMethod method, String path) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + RequestLine line = new RequestLine( + method, pathView, null, + new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), + new HeaderMap(new byte[0]) + ); + return new Request(line, new byte[0]); + } + + // --- route --- + + @Test + void route_lazyCompilationAndMatch() { + FastPathRouterImpl router = new FastPathRouterImpl(); + + router.get("/a", (req, res) -> "A"); + router.post("/b", (req, res) -> "B"); + + RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a")); + assertNotNull(res1); + assertEquals("A", res1.handle(null, null)); + + RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b")); + assertNotNull(res2); + assertEquals("B", res2.handle(null, null)); + } + + @Test + void route_noMatch_returnsNull() { + FastPathRouterImpl router = new FastPathRouterImpl(); + router.get("/a", (req, res) -> "A"); + + assertNull(router.route(mockRequest(HttpMethod.GET, "/b"))); + // Wrong method + assertNull(router.route(mockRequest(HttpMethod.POST, "/a"))); + } + + @Test + void route_extractsPathParams() { + FastPathRouterImpl router = new FastPathRouterImpl(); + + router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract"); + + Request request = mockRequest(HttpMethod.GET, "/users/123/items/456"); + RequestHandler handler = router.route(request); + + assertNotNull(handler); + assertEquals("Extract", handler.handle(request, null)); + + // Verify path params were injected + assertNotNull(request.getPathParams()); + assertEquals("123", request.getPathParam("id")); + assertEquals("456", request.getPathParam("itemId")); + } +} diff --git a/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java new file mode 100644 index 0000000..1c04683 --- /dev/null +++ b/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java @@ -0,0 +1,64 @@ +package dev.relism.routing.routers.fastpathrouter; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class FastPathViewsTest { + + private static final byte[] SHARED_BUFFER = "GET /api/users?id=1 HTTP/1.1".getBytes(StandardCharsets.UTF_8); + + // --- RequestByteView --- + + @Test + void requestByteView_readsCorrectSlice() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); + assertEquals(10, view.length()); + assertEquals('/', view.byteAt(0)); + assertEquals('s', view.byteAt(9)); + assertEquals("/api/users", view.toString()); + } + + @Test + void requestByteView_outOfBounds_throwsException() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10)); + } + + // --- MethodPathByteView --- + + @Test + void methodPathByteView_combinesViewsCorrectly() { + byte[] methodBytes = "POST".getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); // "/api/users" + + FastPathViews.MethodPathByteView composite = new FastPathViews.MethodPathByteView(); + composite.reset(methodBytes, pathView); + + assertEquals(14, composite.length()); + assertEquals('P', composite.byteAt(0)); + assertEquals('T', composite.byteAt(3)); + assertEquals('/', composite.byteAt(4)); + assertEquals('s', composite.byteAt(13)); + } + + // --- SocketByteView & StringByteView --- + + @Test + void socketByteView_wrapsByteArray() { + byte[] data = "Hello".getBytes(StandardCharsets.UTF_8); + FastPathViews.SocketByteView view = new FastPathViews.SocketByteView(data); + assertEquals(5, view.length()); + assertEquals('H', view.byteAt(0)); + } + + @Test + void stringByteView_wrapsString() { + FastPathViews.StringByteView view = new FastPathViews.StringByteView("Hello"); + assertEquals(5, view.length()); + assertEquals('o', view.byteAt(4)); + } +} diff --git a/src/test/java/dev/relism/template/ByteTemplateTest.java b/src/test/java/dev/relism/template/ByteTemplateTest.java new file mode 100644 index 0000000..12cbcfa --- /dev/null +++ b/src/test/java/dev/relism/template/ByteTemplateTest.java @@ -0,0 +1,58 @@ +package dev.relism.template; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ByteTemplateTest { + + // --- render --- + + @Test + void render_singlePlaceholder() { + ByteTemplate tpl = new ByteTemplate("Hello {{name}}!"); + byte[] result = tpl.render("name", "World"); + assertEquals("Hello World!", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_multiplePlaceholders() { + ByteTemplate tpl = new ByteTemplate("{{greeting}} {{name}}, welcome to {{place}}"); + byte[] result = tpl.render( + "greeting", "Hi", + "name", "Alice", + "place", "Wonderland" + ); + assertEquals("Hi Alice, welcome to Wonderland", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_repeatedPlaceholder() { + ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}"); + byte[] result = tpl.render("var", "test"); + assertEquals("test == test", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_unmatchedPlaceholder_leavesEmptySpace() { + ByteTemplate tpl = new ByteTemplate("A{{foo}}B"); + byte[] result = tpl.render("bar", "baz"); // foo is missing + assertEquals("AB", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_noPlaceholders_returnsIdenticalOutput() { + ByteTemplate tpl = new ByteTemplate("Static Content Only"); + byte[] result = tpl.render("ignored", "value"); + assertEquals("Static Content Only", new String(result, StandardCharsets.UTF_8)); + } + + @Test + void render_adjacentPlaceholders() { + ByteTemplate tpl = new ByteTemplate("A{{v1}}{{v2}}B"); + byte[] result = tpl.render("v1", "1", "v2", "2"); + assertEquals("A12B", new String(result, StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/dev/relism/template/ErrorPagesTest.java b/src/test/java/dev/relism/template/ErrorPagesTest.java new file mode 100644 index 0000000..6d12a1b --- /dev/null +++ b/src/test/java/dev/relism/template/ErrorPagesTest.java @@ -0,0 +1,59 @@ +package dev.relism.template; + +import dev.relism.http.HttpMethod; +import dev.relism.models.HeaderMap; +import dev.relism.models.Request; +import dev.relism.models.RequestLine; +import dev.relism.routing.routers.fastpathrouter.FastPathViews; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ErrorPagesTest { + + // --- helpers --- + + private Request mockRequest() { + String path = "/api/test"; + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + + String protocol = "HTTP/1.1"; + byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length); + + RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap(new byte[0])); + return new Request(line, new byte[0]); + } + + // --- templates --- + + @Test + void renderNotFound_generatesHtml() { + Request req = mockRequest(); + byte[] html = ErrorPages.renderNotFound(req); + String result = new String(html, StandardCharsets.UTF_8); + + assertTrue(result.contains("404")); + assertTrue(result.contains("No route matched this request.")); + assertTrue(result.contains("/api/test")); // Injected path + assertTrue(result.contains("GET")); // Injected method + assertTrue(result.contains("footer-logo")); // Baked-in logo + } + + @Test + void renderException_generatesHtml() { + Request req = mockRequest(); + Exception ex = new IllegalArgumentException("Invalid state in test application"); + byte[] html = ErrorPages.renderException(req, ex); + String result = new String(html, StandardCharsets.UTF_8); + + assertTrue(result.contains("500")); + assertTrue(result.contains("/api/test")); + assertTrue(result.contains("IllegalArgumentException")); + assertTrue(result.contains("Invalid state in test application")); + assertTrue(result.contains("ErrorPagesTest.java")); // Stacktrace inclusion + } +}