Initial
This commit is contained in:
+39
@@ -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
|
||||||
Generated
+10
@@ -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/
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-bench/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-bench/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EntryPointsManager">
|
||||||
|
<writeAnnotations>
|
||||||
|
<writeAnnotation name="lombok.Getter" />
|
||||||
|
</writeAnnotations>
|
||||||
|
</component>
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="ignoredFiles">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>Flash</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<modules>
|
||||||
|
<module>flash-bench</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>reposilite-repository-releases</id>
|
||||||
|
<name>Reposilite Repository</name>
|
||||||
|
<url>https://maven.relism.dev/releases</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.11.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.44</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>fpr-core</artifactId>
|
||||||
|
<version>1.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Logging facade — always on classpath; users provide their own SLF4J binding -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.16</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Dev-only binding (simple stdout); excluded from any distributed artifact -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-simple</artifactId>
|
||||||
|
<version>2.0.16</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.44</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism;
|
||||||
|
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@NoArgsConstructor
|
||||||
|
public final class Flash {
|
||||||
|
public static final String VERSION = "5.0.0-dev";
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Internally, the server owns a {@link GlobalRouter} with two tiers of routing:
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Mounted sub-routers</b> — registered via {@link #mount}. Each owns a namespace
|
||||||
|
* prefix (e.g. {@code /api}) and handles all paths under it. Matched by longest prefix first.</li>
|
||||||
|
* <li><b>Internal router</b> — the fallback used when no sub-router claims the path.
|
||||||
|
* Routes registered directly on the server ({@link #get}, {@link #post}, etc.) go here.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Error handlers ({@link #onNotFound}, {@link #onException}) registered on the server
|
||||||
|
* apply only to the internal router. Each mounted sub-router has its own independent handlers.
|
||||||
|
*/
|
||||||
|
@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<Void> 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<Void> 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<Void> 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.
|
||||||
|
*
|
||||||
|
* <p>Any request whose path starts with {@code namespace} will be dispatched to {@code router}
|
||||||
|
* instead of the internal router. When multiple namespaces match, the longest prefix wins.
|
||||||
|
* The router's own {@code onNotFound} and {@code onException} handlers are used for its paths —
|
||||||
|
* the server-level handlers do not apply.
|
||||||
|
*
|
||||||
|
* @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class HttpServerConfiguration {
|
||||||
|
private int port;
|
||||||
|
private String host;
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Integer, byte[]> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>. */
|
||||||
|
public List<String> getAll(String name) {
|
||||||
|
List<String> 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<String>. */
|
||||||
|
public List<String> getAll() {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.relism.models;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred request body reader.
|
||||||
|
*
|
||||||
|
* <p>On the first call to {@link #get()}, the body is materialized: any bytes already
|
||||||
|
* buffered from the 8 KB header read-ahead are copied first, then the remainder is pulled
|
||||||
|
* from the socket stream. The result is cached so subsequent calls are free.
|
||||||
|
*
|
||||||
|
* <p>When {@code contentLength} is zero the instance is pre-resolved to an empty array
|
||||||
|
* and no stream access ever occurs.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package dev.relism.models;
|
||||||
|
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path parameters captured during routing.
|
||||||
|
*
|
||||||
|
* <p>Values are stored as byte offsets into the original path view. String conversion
|
||||||
|
* is lazy and happens only on {@link #get}; {@link #view} is zero-copy.
|
||||||
|
* Lookup is a linear scan — param counts are always small (typically 1–3).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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<String> getAll(String name) {
|
||||||
|
if (raw == null) return List.of();
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); }
|
||||||
|
public List<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.relism.models;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for class-based route handlers.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Each router has a <b>namespace</b> (e.g. {@code /api}). Routes added via
|
||||||
|
* {@link #get}, {@link #post}, etc. are <em>relative</em> to the namespace; the
|
||||||
|
* implementation prepends it when registering. The namespace is {@code "/"} by default
|
||||||
|
* and is set automatically by {@link GlobalRouter#mount}.
|
||||||
|
*
|
||||||
|
* <p>Error handlers ({@link #onNotFound}, {@link #onException}) are scoped to this router.
|
||||||
|
* When a sub-router is mounted on the server, its handlers take precedence over the
|
||||||
|
* server-level ones for all paths under its namespace.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>The handler's return value drives the response:
|
||||||
|
* <ul>
|
||||||
|
* <li>Return a {@link Response} to replace the entire response object.</li>
|
||||||
|
* <li>Return any other non-null value to use it as the body (via {@code toString()} or raw bytes).</li>
|
||||||
|
* <li>Return {@code null} to leave the response unchanged from what was set on the {@code Response} parameter.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>Routing order on each request:
|
||||||
|
* <ol>
|
||||||
|
* <li>Iterates mounted sub-routers in descending namespace length order (longest prefix first)
|
||||||
|
* and delegates to the first one whose namespace is a prefix of the request path.</li>
|
||||||
|
* <li>If no sub-router matches, falls through to the internal {@link FastPathRouterImpl}.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Not intended to be instantiated or subclassed directly — use {@link dev.relism.HttpServer}.
|
||||||
|
*/
|
||||||
|
public class GlobalRouter extends AbstractRouter {
|
||||||
|
private final Map<String, AbstractRouter> subRoutersMap = new HashMap<>();
|
||||||
|
private final List<AbstractRouter> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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}.
|
||||||
|
*
|
||||||
|
* <p>Used by {@link dev.relism.routing.AbstractRouter#register}.
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Route {
|
||||||
|
String method() default "GET";
|
||||||
|
String path();
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Routes are compiled lazily: the internal {@code FastPathRouter} is built on the first
|
||||||
|
* incoming request after one or more routes have been added. Adding a route after the server
|
||||||
|
* has started is safe — the router is marked dirty and recompiled on the next request.
|
||||||
|
*
|
||||||
|
* <p>Matching is done on a virtual {@code METHOD + path} byte sequence to avoid a two-step
|
||||||
|
* lookup. Captured path parameters are exposed via {@link dev.relism.models.PathParams} on
|
||||||
|
* the request object, accessible through {@link dev.relism.models.Request#getPathParams()}.
|
||||||
|
*
|
||||||
|
* <p>Thread safety: {@code FastPathRouter.match()} is safe for concurrent calls as of
|
||||||
|
* {@code fpr-core} 1.1.0 — traversal state ({@code RouteSearch}, {@code SegmentCursor}) is
|
||||||
|
* held in method-local variables, making the compiled router instance fully immutable.
|
||||||
|
*/
|
||||||
|
public class FastPathRouterImpl extends AbstractRouter {
|
||||||
|
private final RouterBuilder<RequestHandler> builder = new RouterBuilder<>();
|
||||||
|
private volatile FastPathRouter<ByteView, RequestHandler> router;
|
||||||
|
private String[] cachedParamNames;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-local holders for per-request reusable objects.
|
||||||
|
* Both {@link MatchResult} and {@link FastPathViews.MethodPathByteView} are reset before use,
|
||||||
|
* so no allocation occurs on the routing hot path.
|
||||||
|
*/
|
||||||
|
private static final class FastPathRouterContext {
|
||||||
|
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
|
||||||
|
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
||||||
|
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED_VIEW_HOLDER =
|
||||||
|
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
|
||||||
|
|
||||||
|
public static MatchResult<RequestHandler> 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<RequestHandler> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.routing.routers.radix;
|
||||||
|
|
||||||
|
public class RadixPathRouterImpl {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* Placeholders of the form {@code {{name}}} are detected once at construction.
|
||||||
|
* Each {@link #render} call makes exactly one allocation: the output byte[].
|
||||||
|
* <p>
|
||||||
|
* 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<byte[]> segs = new ArrayList<>();
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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 "<img class=\"footer-logo\" src=\"data:image/png;base64," + b64 + "\" alt=\"Flash\">";
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>404 — Flash</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0c;
|
||||||
|
--text: #a0a0a0;
|
||||||
|
--muted: #555;
|
||||||
|
--faint: #333;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--token-path: #7ab0ff;
|
||||||
|
--token-method: #c4b5fd;
|
||||||
|
--heading: #e8e8e8;
|
||||||
|
--divider-bg: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--text: #555;
|
||||||
|
--muted: #999;
|
||||||
|
--faint: #bbb;
|
||||||
|
--surface: #efefef;
|
||||||
|
--border: #ddd;
|
||||||
|
--token-path: #2563eb;
|
||||||
|
--token-method: #7c3aed;
|
||||||
|
--heading: #111;
|
||||||
|
--divider-bg: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html { height: 100% }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font: 15px/1.5 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { max-width: 480px; width: 100% }
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -3px;
|
||||||
|
color: var(--heading);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 48px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--divider-bg);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method { color: var(--token-method) }
|
||||||
|
.path { color: var(--token-path) }
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto {
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo {
|
||||||
|
height: 14px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.55;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand { color: var(--text); font-weight: 600 }
|
||||||
|
.sep { color: var(--faint); font-size: 14px; line-height: 1 }
|
||||||
|
time { font-variant-numeric: tabular-nums }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<main>
|
||||||
|
<div class="code">404</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p><span class="route"><span class="method">{{method}}</span> <span class="path">{{path}}</span></span></p>
|
||||||
|
<p class="msg">No route matched this request.</p>
|
||||||
|
<p class="proto">{{protocol}}</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
{{logo_img}}
|
||||||
|
<span class="footer-brand">Flash 5</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>v{{version}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<time>{{timestamp}}</time>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>500 — Flash</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0c;
|
||||||
|
--text: #a0a0a0;
|
||||||
|
--muted: #555;
|
||||||
|
--faint: #333;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--token-path: #7ab0ff;
|
||||||
|
--token-method: #c4b5fd;
|
||||||
|
--heading: #e8e8e8;
|
||||||
|
--divider-bg: #442222;
|
||||||
|
--err-label: #f87171;
|
||||||
|
--err-msg: #fca5a5;
|
||||||
|
--code-bg: #111;
|
||||||
|
--code-border: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--text: #555;
|
||||||
|
--muted: #999;
|
||||||
|
--faint: #bbb;
|
||||||
|
--surface: #efefef;
|
||||||
|
--border: #ddd;
|
||||||
|
--token-path: #2563eb;
|
||||||
|
--token-method: #7c3aed;
|
||||||
|
--heading: #111;
|
||||||
|
--divider-bg: #f5c5c5;
|
||||||
|
--err-label: #dc2626;
|
||||||
|
--err-msg: #ef4444;
|
||||||
|
--code-bg: #f9f9f9;
|
||||||
|
--code-border: #ddd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html { height: 100% }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font: 15px/1.5 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { max-width: 560px; width: 100% }
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -3px;
|
||||||
|
color: var(--heading);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 48px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--divider-bg);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method { color: var(--token-method) }
|
||||||
|
.path { color: var(--token-path) }
|
||||||
|
|
||||||
|
.exc-type {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: var(--err-label);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exc-msg { color: var(--err-msg); font-weight: 400 }
|
||||||
|
|
||||||
|
.trace {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--code-border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto {
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 12px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo {
|
||||||
|
height: 14px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.55;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand { color: var(--text); font-weight: 600 }
|
||||||
|
.sep { color: var(--faint); font-size: 14px; line-height: 1 }
|
||||||
|
time { font-variant-numeric: tabular-nums }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<main>
|
||||||
|
<div class="code">500</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p><span class="route"><span class="method">{{method}}</span> <span class="path">{{path}}</span></span></p>
|
||||||
|
<p class="exc-type">{{exception_type}}<span class="exc-msg">: {{exception_message}}</span></p>
|
||||||
|
<pre class="trace">{{stacktrace}}</pre>
|
||||||
|
<p class="proto">{{protocol}}</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
{{logo_img}}
|
||||||
|
<span class="footer-brand">Flash 5</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>v{{version}}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<time>{{timestamp}}</time>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -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<String> 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<String> get(String path) throws Exception {
|
||||||
|
return get(port, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> 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<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
List<String> responses = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
pool.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
HttpResponse<String> 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<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < perMethod; i++) {
|
||||||
|
pool.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
HttpResponse<String> 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<String> 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<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
final int routeIdx = i % 10;
|
||||||
|
pool.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
HttpResponse<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user