preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
+35 -56
View File
@@ -5,11 +5,12 @@ import dev.relism.http.ContentType;
import dev.relism.http.HttpStatus;
import dev.relism.models.*;
import dev.relism.extension.FlashConfiguration;
import dev.relism.routing.GlobalRouter;
import dev.relism.routing.AbstractRouter;
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
@@ -21,32 +22,32 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread executor,
* and the keep-alive accept loop. All routing is delegated to the {@link GlobalRouter}
* supplied at construction time.
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
* executor, and the keep-alive accept loop. Routing is delegated to a single
* {@link AbstractRouter}.
*
* <p>This class is package-private use {@link dev.relism.extension.FlashApp} as the
* single entry point for creating and configuring a Flash server.
* <p>Package-private : use {@link dev.relism.extension.FlashApp} as the single
* entry point.
*/
@Slf4j
class HttpServer implements ServerHandle {
private final FlashConfiguration configuration;
private final ServerSocket serverSocket;
private final GlobalRouter globalRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private final ServerSocket serverSocket;
private final AbstractRouter router;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
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[] 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[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\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];
@@ -55,20 +56,12 @@ class HttpServer implements ServerHandle {
DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8);
}
/**
* Creates the transport with a pre-built router. Called exclusively by
* {@link dev.relism.extension.FlashApp}.
*
* @param configuration server configuration (port, host, buffer sizes)
* @param globalRouter the fully-wired router to dispatch requests to
*/
HttpServer(FlashConfiguration configuration, GlobalRouter globalRouter) throws IOException {
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
this.configuration = configuration;
this.serverSocket = new ServerSocket(configuration.getPort());
this.globalRouter = globalRouter;
this.router = router;
}
/** Returns a future that completes once the accept loop is running and the server is ready. */
@Override
public CompletableFuture<Void> start() {
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
@@ -90,15 +83,10 @@ class HttpServer implements ServerHandle {
}
}
/** Closes all active connections and shuts down the executor. Returns when complete. */
@Override
public CompletableFuture<Void> stop() {
stopped = true;
try {
serverSocket.close();
} catch (IOException e) {
log.error("Error closing server socket", e);
}
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown();
try {
@@ -111,7 +99,7 @@ class HttpServer implements ServerHandle {
return CompletableFuture.completedFuture(null);
}
// ── Hot-path ─────────────────────────────────────────────────────────────
// ── Hot-path ─────────────────────────────────────────────────────────────
private void process(Socket socket) {
activeSockets.add(socket);
@@ -119,36 +107,32 @@ class HttpServer implements ServerHandle {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
RequestParser parser = new RequestParser(configuration.getMaxHeaderBufferSize());
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress());
while (!stopped) {
Request request = parser.parse(in);
if (request == null)
break;
if (request == null) break;
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = globalRouter.route(request);
RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response r)
response = r;
else if (result != null)
response.setBody(result);
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);
Object result = router.getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
}
writeResponse(out, response, keepAlive);
request.drain();
if (!keepAlive)
break;
if (!keepAlive) break;
}
} catch (IOException e) {
if (!stopped) {
@@ -170,11 +154,6 @@ class HttpServer implements ServerHandle {
|| request.headerEquals("Connection", "keep-alive");
}
/**
* Writes a complete HTTP response. The fixed-body path (the common case for simple handlers
* like /plaintext) is kept inline; streaming and chunked bodies are delegated to
* {@link #writeStreamingBody} so the JIT can optimise this method aggressively.
*/
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
out.write(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
@@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.util.Arrays;
/**
@@ -21,15 +22,18 @@ import java.util.Arrays;
public class RequestParser {
private static final int INITIAL_BUFFER_SIZE = 8192;
private final int maxHeaderBufferSize;
private final HeaderMap headerMap = new HeaderMap();
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress; // set once per connection, never changes
private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer;
private int bufBase = 0; // absolute start of valid data in buffer
private int bufLen = 0; // number of valid bytes from bufBase
public RequestParser() { this(64 * 1024); }
public RequestParser(int maxHeaderBufferSize) {
public RequestParser() { this(64 * 1024, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
this.maxHeaderBufferSize = maxHeaderBufferSize;
this.remoteAddress = remoteAddress;
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
}
@@ -133,9 +137,9 @@ public class RequestParser {
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
if (isChunked) {
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0, remoteAddress);
}
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen);
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
}
private static int findEndOfHeader(byte[] buf, int from, int len) {
@@ -1,26 +1,22 @@
package dev.relism;
import dev.relism.extension.FlashConfiguration;
import dev.relism.routing.GlobalRouter;
import dev.relism.routing.AbstractRouter;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
/**
* Public handle to the underlying HTTP transport. Returned by {@link #create} so that
* {@link dev.relism.extension.FlashApp} can start and stop the server without holding
* a direct reference to the package-private {@link HttpServer}.
* Public handle to the underlying HTTP transport. Returned by {@link #create}
* so that {@link dev.relism.extension.FlashApp} can start and stop the server
* without holding a direct reference to the package-private {@link HttpServer}.
*/
public interface ServerHandle {
CompletableFuture<Void> start();
CompletableFuture<Void> stop();
/**
* Creates the HTTP transport. Called exclusively by
* {@link dev.relism.extension.FlashApp}.
*/
static ServerHandle create(FlashConfiguration config, GlobalRouter router) throws IOException {
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
return new HttpServer(config, router);
}
}
@@ -1,7 +0,0 @@
package dev.relism.exceptions;
public class DuplicateNamespaceException extends RuntimeException {
public DuplicateNamespaceException(String namespace) {
super("Router with namespace '" + namespace + "' is already registered.");
}
}
@@ -0,0 +1,18 @@
package dev.relism.exceptions;
/**
* Thrown at boot time when Flash detects a configuration or registration error.
*
* <p>Fail-fast: a clear crash at startup is always preferable to a server that
* starts "empty" and silently drops routes.
*/
public class InitializationException extends RuntimeException {
public InitializationException(String message) {
super(message);
}
public InitializationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -14,7 +14,7 @@ import java.util.List;
* valid — processors may also use the call purely for side effects
* (e.g. collecting OpenAPI metadata).
*
* <p>Register processors via {@link ExtensionContext#addAnnotationProcessor}.
* <p>Register processors via {@link FlashContext#addAnnotationProcessor}.
*/
@FunctionalInterface
public interface AnnotationProcessor {
@@ -1,14 +1,15 @@
package dev.relism.extension;
import dev.relism.ServerHandle;
import dev.relism.exceptions.InitializationException;
import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.GlobalRouter;
import dev.relism.routing.Middleware;
import dev.relism.routing.Route;
import dev.relism.routing.RouteHandle;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import java.io.IOException;
import java.util.ArrayList;
@@ -16,98 +17,62 @@ import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.stream.Stream;
/**
* Primary entry point for Flash. Creates and owns both the {@link GlobalRouter} and
* the {@link HttpServer} (pure I/O transport). All route registration goes through
* {@code FlashApp} or a {@link FlashScope} — never through the server or router directly.
* Single entry point for Flash. Owns one flat {@link FastPathRouterImpl} —
* all routes (app-level and scoped) compile into a single FSM at {@link #start()}.
*
* <p>Create via the static factories:
* <pre>{@code
* FlashApp app = FlashApp.create(8080);
* FlashApp app = FlashApp.create(FlashConfiguration.builder().port(8080).build());
* }</pre>
* <h3>Deferred routing</h3>
* Routes accumulate during the builder phase. At {@code start()}:
* <ol>
* <li>Global middlewares are prepended to every route</li>
* <li>Annotation processors run for class-based handlers</li>
* <li>Handlers are bound to the {@link FlashContext}</li>
* <li>All routes compile into one FSM — zero prefix scanning at runtime</li>
* </ol>
*
* <p>Install extensions, register routes, mount namespaces, then start:
* <pre>{@code
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .install(new OidcExtension(config))
* .use(cors)
* .get("/ping", (req, res) -> "pong")
* .get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect())
* .register(new HomePage()) // @Route + annotation processors applied
* .scan("dev.example.handlers") // classpath scan, no-arg constructors
* .mount("/api", scope -> {
* scope.register(new UserHandler()); // @Authenticated works here
* scope.get("/health", (req, res) -> "ok");
* })
* .scan("dev.example.handlers")
* .mount("/api", scope -> scope.get("/health", (req, res) -> "ok"))
* .start();
* }</pre>
*
* <h3>Auto-flush</h3>
* Calling any registration method returns a {@link RouteHandle}. Calling
* {@link RouteHandle#with} is optional — if omitted, the route is registered
* automatically before the next operation or at {@link #start()}. This means
* trailing {@code .with()} calls are never required for routes with no middleware.
*/
public final class FlashApp implements FlashRegistrar {
private final GlobalRouter router;
private final ServerHandle server;
private final ExtensionContext ctx = new ExtensionContext();
private final AbstractRouter router = new FastPathRouterImpl();
private final ServerHandle server;
private final FlashContext ctx = new FlashContext();
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
/** The last returned RouteHandle not yet registered — auto-flushed before the next operation. */
private RouteHandle<?> pending;
/**
* Global middlewares applied to every route, regardless of how it is registered
* (lambda, class-based, or via {@link #scan}).
* Accumulated via {@link #use}; applied outermost in the chain (before injected and
* explicit middlewares).
*/
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private FlashApp(FlashConfiguration config) {
this.router = new GlobalRouter();
try {
this.server = ServerHandle.create(config, router);
} catch (IOException e) {
throw new RuntimeException("Failed to bind server socket on port " + config.getPort(), e);
throw new InitializationException("Failed to bind on port " + config.getPort(), e);
}
}
// ── Factories ─────────────────────────────────────────────────────────────
/**
* Creates a {@code FlashApp} listening on {@code port} with default configuration.
*
* @param port the TCP port to bind
*/
public static FlashApp create(int port) {
return create(FlashConfiguration.builder().port(port).build());
}
/**
* Creates a {@code FlashApp} with full server configuration.
*
* @param config server configuration (port, host, buffer sizes, etc.)
*/
public static FlashApp create(FlashConfiguration config) {
return new FlashApp(config);
}
// ── Pending flush ─────────────────────────────────────────────────────────
/**
* Registers any pending route (from the previous {@code get/post/register} call)
* with no middleware if it has not already been committed via {@link RouteHandle#with}.
*/
private void flushPending() {
if (pending != null) {
pending.ensureRegistered();
pending = null;
}
if (pending != null) { pending.ensureRegistered(); pending = null; }
}
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
@@ -116,15 +81,8 @@ public final class FlashApp implements FlashRegistrar {
return handle;
}
// ── FlashRegistrar — extension installation ───────────────────────────────
// ── Extension installation ────────────────────────────────────────────────
/**
* Installs an extension. Extensions receive this {@code FlashApp} as a
* {@link FlashRegistrar} so they can register routes and expose services.
*
* @param ext the extension to install
* @return {@code this} for chaining
*/
@Override
public FlashApp install(FlashExtension ext) {
flushPending();
@@ -132,34 +90,12 @@ public final class FlashApp implements FlashRegistrar {
return this;
}
// ── Global middleware ─────────────────────────────────────────────────────
// ── Global middleware ─────────────────────────────────────────────────────
/**
* Registers one or more global middlewares applied to <em>every</em> route on this app,
* regardless of how the route is registered (lambda, class-based, or via {@link #scan}).
*
* <p>Global middlewares execute outermost — before annotation-injected middlewares
* (e.g. {@code @Authenticated}) and before any explicit {@link RouteHandle#with} chain.
* Execution order mirrors the declaration order: the first argument wraps everything else.
*
* <p>Must be called before {@link #start()}. Calling {@code use} after routes have already
* been registered will not retroactively affect those routes.
*
* <pre>{@code
* Middleware cors = next -> (req, res) -> {
* res.header("Access-Control-Allow-Origin", "*");
* if (req.method() == HttpMethod.OPTIONS) { res.status(204); return null; }
* return next.handle(req, res);
* };
*
* FlashApp.create(8080)
* .use(cors)
* .scan("dev.example.handlers")
* .start();
* }</pre>
*
* @param middlewares one or more middlewares to apply globally
* @return {@code this} for chaining
* Registers global middlewares applied to <em>every</em> route — including
* routes registered before this call and routes inside mounted scopes.
* Order-independent: resolved at {@link #start()}.
*/
public FlashApp use(Middleware... middlewares) {
flushPending();
@@ -167,182 +103,157 @@ public final class FlashApp implements FlashRegistrar {
return this;
}
/**
* Prepends global middlewares to an explicit per-route array.
* Returns {@code explicit} unchanged when no global middlewares have been registered
* (zero-allocation fast path).
*/
private Middleware[] withGlobal(Middleware[] explicit) {
if (globalMiddlewares.isEmpty()) return explicit;
return Stream.concat(globalMiddlewares.stream(), Arrays.stream(explicit))
.toArray(Middleware[]::new);
}
// ── Route registration (deferred) ─────────────────────────────────────────
// ── FlashRegistrar — route registration ───────────────────────────────────
@Override public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashApp> trace (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashApp> connect(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashApp> purge (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PURGE, path, h); }
@Override public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashApp> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashApp> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashApp> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
private static final Middleware[] NO_MW = new Middleware[0];
private RouteHandle<FlashApp> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
return track(new RouteHandle<>(this, m -> {
Middleware[] all = withGlobal(m);
emit(method, path, null, List.of(), all);
router.doRegister(method, path, h, all);
}));
private RouteHandle<FlashApp> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
return track(new RouteHandle<>(this, mw ->
deferredRoutes.add(new RouteDefinition(method, path, new SimpleHandler(h), NO_MW, mw, false, ctx, "/"))));
}
/**
* Begins registration of a class-based handler. The class must carry a
* {@link Route @Route} annotation. All registered {@link AnnotationProcessor}s
* are run (e.g. to inject {@code @Authenticated} / {@code @RolesAllowed} middleware).
* Injected middlewares are prepended outermost to any explicit ones passed via
* {@link RouteHandle#with}.
*
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
* automatically before the next operation or at {@link #start()}.
* Registers a class-based handler annotated with {@link Route @Route}.
* App-level only — not part of {@link FlashRegistrar}.
*/
@Override
public RouteHandle<FlashApp> register(RequestHandler handler) {
return track(new RouteHandle<>(this, explicit -> {
List<Middleware> injected = ctx.processors().stream()
.flatMap(p -> p.process(handler.getClass()).stream())
.toList();
Middleware[] all = Stream.concat(
globalMiddlewares.stream(),
Stream.concat(injected.stream(), Arrays.stream(explicit))
).toArray(Middleware[]::new);
Route ann = handler.getClass().getAnnotation(Route.class);
if (ann != null) emit(ann.method(), ann.path(), handler.getClass(), injected, withGlobal(explicit));
router.doRegister(handler, all);
}));
Route ann = handler.getClass().getAnnotation(Route.class);
if (ann == null)
throw new InitializationException(
handler.getClass().getName() + " is missing @Route");
return track(new RouteHandle<>(this, mw ->
deferredRoutes.add(new RouteDefinition(ann.method(), ann.path(), handler, NO_MW, mw, true, ctx, "/"))));
}
/**
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
* carry {@link Route @Route}. Each is instantiated via its no-arg constructor,
* run through annotation processors, and registered.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new OidcExtension(config))
* .scan("dev.example.handlers"); // @Authenticated / @RolesAllowed auto-applied
* }</pre>
*/
@Override
public FlashApp scan(String packageName) {
flushPending();
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this;
}
// ── Namespace mounting ───────────────────────────────────────────────────
// ── Namespace mounting (syntactic sugar — routes go into same flat router)
/**
* Mounts a scoped sub-router under {@code namespace}. The {@code configure} consumer
* receives a {@link FlashScope} that has its own child {@link ExtensionContext}
* inheriting all parent services and annotation processors.
*
* <p>Routes registered on the scope automatically get the namespace prefix prepended.
* Annotation processors (e.g. from OIDC) apply identically inside the scope.
*
* <pre>{@code
* app.mount("/api", scope -> {
* scope.register(new UserHandler()); // @Authenticated works
* scope.get("/health", (req, res) -> "ok");
* scope.scan("dev.example.api");
* });
* }</pre>
*
* @param namespace the path prefix (e.g. {@code "/api"})
* @param configure consumer that registers routes on the scope
* Mounts a scoped group of routes under {@code namespace}. The scope is a
* pure builder — it prepends the namespace to each path and collects
* {@link RouteDefinition}s that merge into this app's single flat router.
*/
public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
flushPending();
FlashScope scope = new FlashScope(new dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl(),
namespace, ctx);
FlashScope scope = new FlashScope(namespace, ctx);
configure.accept(scope);
scope.flush();
router.mount(namespace, scope.router());
deferredRoutes.addAll(scope.routes());
return this;
}
// ── FlashRegistrar — error handlers ──────────────────────────────────────
// ── Error handlers ────────────────────────────────────────────────────────
@Override
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
flushPending();
router.onException(handler);
return this;
}
@Override
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
flushPending();
router.onNotFound(handler);
return this;
}
// ── FlashRegistrar — context ──────────────────────────────────────────────
@Override
public ExtensionContext ctx() {
return ctx;
}
public FlashContext ctx() { return ctx; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
/**
* Flushes any pending route registration and starts the HTTP server.
*
* @return a future that completes once the accept loop is running
* Compiles all deferred routes into the flat FSM router, then starts
* the HTTP transport. One pass, one router, zero prefix scanning.
*/
public CompletableFuture<Void> start() {
flushPending();
compile();
return server.start();
}
/** Stops the HTTP server and closes all active connections. */
public CompletableFuture<Void> stop() {
return server.stop();
public CompletableFuture<Void> stop() { return server.stop(); }
// ── Compilation ──────────────────────────────────────────────────────────
/**
* Compiles all deferred routes. Middleware chain order:
* Global → Scope → Annotation (class-based only) → Explicit (.with).
*/
private void compile() {
for (RouteDefinition def : deferredRoutes) {
List<Middleware> injected;
if (def.classBasedHandler()) {
injected = def.ctx().processors().stream()
.flatMap(p -> p.process(def.handler().getClass()).stream())
.toList();
def.handler().bind(def.ctx());
} else {
injected = List.of();
}
Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(),
injected, def.explicitMiddlewares());
emitEvent(def, all);
router.doRegister(def.method(), def.path(), def.handler(), all);
}
}
@SuppressWarnings("unchecked")
private void emitEvent(RouteDefinition def, Middleware[] allMiddlewares) {
List<RouteListener> listeners = def.ctx().routeListeners();
if (listeners.isEmpty()) return;
List<Class<? extends Middleware>> chain = new ArrayList<>(allMiddlewares.length);
for (Middleware m : allMiddlewares)
chain.add((Class<? extends Middleware>) m.getClass());
Class<?> handlerClass = def.classBasedHandler() ? def.handler().getClass() : null;
RouteEvent event = new RouteEvent(def.method(), def.path(), def.namespace(),
"FlashApp", handlerClass, List.copyOf(chain));
listeners.forEach(l -> l.onRoute(event));
}
// ── Internals ─────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
private static Middleware[] concat(List<Middleware> global, Middleware[] scope,
List<Middleware> injected, Middleware[] explicit) {
int total = global.size() + scope.length + injected.size() + explicit.length;
if (total == 0) return NO_MW;
if (total == explicit.length && scope.length == 0) return explicit;
Middleware[] all = new Middleware[total];
int i = 0;
for (Middleware m : global) all[i++] = m;
for (Middleware m : scope) all[i++] = m;
for (Middleware m : injected) all[i++] = m;
System.arraycopy(explicit, 0, all, i, explicit.length);
return all;
}
private static RequestHandler instantiate(Class<?> cls) {
try {
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
/**
* Emits a {@link RouteEvent} to all registered {@link RouteListener}s.
* No-op if no listener has been registered (fast empty-list check).
* Called once per route at boot time — never on the request hot-path.
*/
@SuppressWarnings("unchecked")
private void emit(HttpMethod method, String path, Class<?> handlerClass,
List<Middleware> injected, Middleware[] explicit) {
List<RouteListener> listeners = ctx.routeListeners();
if (listeners.isEmpty()) return;
Middleware[] routerMws = router.routerMiddlewares();
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
RouteEvent event = new RouteEvent(method, path, router.getNamespace(),
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
listeners.forEach(l -> l.onRoute(event));
}
}
@@ -4,46 +4,42 @@ import java.util.*;
import java.util.stream.Stream;
/**
* Shared registry passed to every extension during {@link FlashExtension#install}.
* Extensions use it in two ways:
* Central service registry and boot-time hook coordinator.
*
* <p>Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
* It provides three capabilities:
* <ol>
* <li><b>Service sharing</b> provide/require typed objects (e.g. {@code ObjectMapper},
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
* <li><b>Annotation processing</b> register {@link AnnotationProcessor}s that
* are invoked for every handler, injecting middleware derived from
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
* <li><b>Service registry</b> typed {@link #provide}/{@link #require}/{@link #find}.</li>
* <li><b>Annotation processors</b> middleware injection from handler annotations.</li>
* <li><b>Route listeners</b> boot-time observation of the route graph.</li>
* </ol>
*
* <p>A child context (created via {@link #child()}) inherits all services and processors
* from its parent. Services provided and processors added on the child are scoped to it
* and not visible in the parent or sibling scopes.
* <p>A child context (via {@link #child()}) inherits parent services and processors.
* Services provided on the child are scoped and invisible to the parent.
*/
public class ExtensionContext {
public class FlashContext {
private final ExtensionContext parent;
private final FlashContext parent;
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>();
public ExtensionContext() {
public FlashContext() {
this.parent = null;
}
private ExtensionContext(ExtensionContext parent) {
private FlashContext(FlashContext parent) {
this.parent = parent;
}
/**
* Creates a child context that inherits this context's services and processors.
* Services provided and processors added on the child do not affect the parent.
*/
public ExtensionContext child() {
return new ExtensionContext(this);
/** Creates a child context that inherits this context's services and processors. */
public FlashContext child() {
return new FlashContext(this);
}
// Service registry
/** Stores {@code instance} under {@code type} for retrieval by other extensions. */
/** Stores {@code instance} under {@code type} for retrieval via {@link #require} or {@link #find}. */
public <T> void provide(Class<T> type, T instance) {
registry.put(type, instance);
}
@@ -51,7 +47,8 @@ public class ExtensionContext {
/**
* Retrieves the service registered under {@code type}.
* Checks own scope first, then the parent chain.
* Throws {@link IllegalStateException} if not found install order matters.
*
* @throws IllegalStateException if not found
*/
@SuppressWarnings("unchecked")
public <T> T require(Class<T> type) {
@@ -59,12 +56,12 @@ public class ExtensionContext {
if (val == null && parent != null) val = parent.find(type).orElse(null);
if (val == null)
throw new IllegalStateException(
"Extension dependency not found: " + type.getSimpleName() +
" — install the required extension first");
"Service not found: " + type.getSimpleName() +
" provide it via FlashContext.provide() or install the required extension");
return val;
}
/** Returns the service under {@code type}, or empty if not installed in this scope or any parent. */
/** Returns the service under {@code type}, or empty if not provided in this scope or any parent. */
@SuppressWarnings("unchecked")
public <T> Optional<T> find(Class<T> type) {
T val = (T) registry.get(type);
@@ -72,21 +69,26 @@ public class ExtensionContext {
return parent != null ? parent.find(type) : Optional.empty();
}
/**
* Returns the service registered under {@code type} as an {@link Optional},
* or {@link Optional#empty()} if not present in this scope or any parent.
*
* <p>Semantically identical to {@link #find} prefer this name for expressive call sites
* ({@code ctx.optional(ViewEngine.class).ifPresent(...)}). Respects the parent-first
* scope hierarchy: own registry is checked first, then the parent chain.
*/
public <T> Optional<T> optional(Class<T> type) {
return find(type);
}
// Annotation processors
/**
* Registers an {@link AnnotationProcessor}. Called by extensions during
* {@link FlashExtension#install}. Processors are invoked in registration order
* (parent processors first, then own).
*/
/** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
public void addAnnotationProcessor(AnnotationProcessor processor) {
processors.add(processor);
}
/**
* Returns all processors visible from this context: parent processors first,
* then processors added directly to this context.
*/
/** All processors visible from this context: parent-first, then own. */
List<AnnotationProcessor> processors() {
if (parent == null) return Collections.unmodifiableList(processors);
List<AnnotationProcessor> parentProcessors = parent.processors();
@@ -96,22 +98,12 @@ public class ExtensionContext {
// Route listeners
/**
* Registers a {@link RouteListener} that will be notified once for every route
* registered on this context's {@link dev.relism.extension.FlashApp} or any
* {@link dev.relism.extension.FlashScope} that inherits from it.
*
* <p>Call this inside {@link FlashExtension#install} to observe all routes.
* If no listener is registered the emission path is a no-op.
*/
/** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
public void addRouteListener(RouteListener listener) {
routeListeners.add(listener);
}
/**
* Returns all route listeners visible from this context: parent listeners first,
* then listeners added directly to this context.
*/
/** All route listeners visible from this context: parent-first, then own. */
List<RouteListener> routeListeners() {
if (parent == null) return Collections.unmodifiableList(routeListeners);
List<RouteListener> parentListeners = parent.routeListeners();
@@ -3,17 +3,17 @@ package dev.relism.extension;
/**
* Contract for all Flash extensions. An extension receives a {@link FlashRegistrar}
* (either a {@link FlashApp} or a {@link FlashScope}) so it can register routes and
* expose shared services via {@link ExtensionContext}.
* expose shared services via {@link FlashContext}.
*
* <p>Extensions work identically whether installed at the top-level app or inside a
* mounted scope:
*
* <pre>{@code
* public class RateLimitExtension implements FlashExtension {
* public void install(FlashRegistrar app, ExtensionContext ctx) {
* public void install(FlashRegistrar app, FlashContext ctx) {
* RateLimiter limiter = new RateLimiter(100);
* ctx.provide(RateLimiter.class, limiter);
* app.onException((ex, req, res) -> { ... });
* app.get("/rate-info", (req, res) -> limiter.info());
* }
* }
*
@@ -28,5 +28,5 @@ package dev.relism.extension;
*/
@FunctionalInterface
public interface FlashExtension {
void install(FlashRegistrar app, ExtensionContext ctx);
void install(FlashRegistrar app, FlashContext ctx);
}
@@ -1,23 +1,17 @@
package dev.relism.extension;
import dev.relism.models.RequestHandler;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.RouteHandle;
/**
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
*
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so that extensions
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so extensions
* work identically whether installed at the top-level app or inside a mounted scope.
*
* <p>Route registration follows the auto-flush pattern: calling any registration method
* without a subsequent {@link RouteHandle#with} is equivalent to calling
* {@code .with()} with no arguments — the route is registered with no middleware.
*
* <pre>{@code
* // In an extension:
* public void install(FlashRegistrar app, ExtensionContext ctx) {
* public void install(FlashRegistrar app, FlashContext ctx) {
* app.get("/health", (req, res) -> "ok");
* app.get("/secured", (req, res) -> user()).with(oidc.protect());
* }
@@ -25,11 +19,9 @@ import dev.relism.routing.RouteHandle;
*/
public interface FlashRegistrar {
// ── Extension installation ────────────────────────────────────────────────
FlashRegistrar install(FlashExtension ext);
// ── Route registration ────────────────────────────────────────────────────
// ── Lambda route registration ────────────────────────────────────────────
RouteHandle<?> get (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> post (String path, SimpleHandler.FunctionalHandler h);
@@ -43,29 +35,13 @@ public interface FlashRegistrar {
RouteHandle<?> purge (String path, SimpleHandler.FunctionalHandler h);
/**
* Begins registration of a class-based handler. The class must carry a
* {@link dev.relism.routing.Route @Route} annotation. Annotation processors
* (e.g. {@code @Authenticated}, {@code @RolesAllowed}) are applied automatically.
*
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
* automatically before the next operation or at {@code start()}.
*/
RouteHandle<?> register(RequestHandler h);
/**
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
* carry {@link dev.relism.routing.Route @Route}. Each is instantiated via its
* Scans {@code packageName} for classes that extend
* {@link dev.relism.models.RequestHandler} and carry
* {@link dev.relism.routing.Route @Route}. Each is instantiated via its
* no-arg constructor, run through annotation processors, and registered.
*/
FlashRegistrar scan(String packageName);
// ── Error handlers ────────────────────────────────────────────────────────
FlashRegistrar onException(AbstractRouter.ExceptionHandler h);
FlashRegistrar onNotFound(SimpleHandler.FunctionalHandler h);
// ── Context access ────────────────────────────────────────────────────────
/** Returns the {@link ExtensionContext} for this registrar (app or scope). */
ExtensionContext ctx();
/** Returns the {@link FlashContext} for this registrar. */
FlashContext ctx();
}
@@ -1,62 +1,43 @@
package dev.relism.extension;
import dev.relism.exceptions.InitializationException;
import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.Middleware;
import dev.relism.routing.PathUtils;
import dev.relism.routing.Route;
import dev.relism.routing.RouteHandle;
import dev.relism.routing.Middleware;
import java.io.File;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
/**
* Scoped registration context for a mounted sub-router namespace.
*
* <p>Obtained via {@link FlashApp#mount(String, java.util.function.Consumer)}.
* A scope has its own child {@link ExtensionContext} that inherits all services and
* annotation processors from the parent app, so extensions like {@code @Authenticated}
* and {@code @RolesAllowed} work identically inside a scope.
*
* <p>Extensions installed on a scope are scoped to that namespace and not visible
* in the parent or sibling scopes.
* Scoped builder for namespace-prefixed routes. Pure syntactic sugar —
* does not own a router. Collected routes merge into {@link FlashApp}'s
* single flat router at {@link FlashApp#start()}.
*
* <pre>{@code
* app.mount("/api", scope -> {
* scope.install(new RateLimitExtension());
* scope.register(new UserHandler()); // @Authenticated auto-injected
* scope.get("/health", (req, res) -> "ok");
* scope.use(authMiddleware);
* scope.get("/health", (req, res) -> "ok"); // → GET /api/health
* scope.scan("dev.example.api.handlers");
* });
* }</pre>
*/
public final class FlashScope implements FlashRegistrar {
private final AbstractRouter router;
private final String namespace;
private final ExtensionContext ctx;
private final String namespace;
private final FlashContext ctx;
private final List<Middleware> scopeMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private static final Middleware[] NO_MW = new Middleware[0];
/** Pending RouteHandle awaiting .with() — auto-flushed before each new registration. */
private RouteHandle<?> pending;
/**
* Package-private — only {@link FlashApp} creates scopes.
*
* @param router the sub-router that will receive routes registered on this scope
* @param namespace the namespace prefix (e.g. {@code "/api"})
* @param parentCtx the parent app's ExtensionContext — a child is created from it
*/
FlashScope(AbstractRouter router, String namespace, ExtensionContext parentCtx) {
this.router = router;
FlashScope(String namespace, FlashContext parentCtx) {
this.namespace = namespace;
this.ctx = parentCtx.child();
}
@@ -64,10 +45,7 @@ public final class FlashScope implements FlashRegistrar {
// ── Pending flush ─────────────────────────────────────────────────────────
private void flushPending() {
if (pending != null) {
pending.ensureRegistered();
pending = null;
}
if (pending != null) { pending.ensureRegistered(); pending = null; }
}
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
@@ -76,12 +54,20 @@ public final class FlashScope implements FlashRegistrar {
return handle;
}
// ── FlashRegistrar — extension installation ───────────────────────────────
// ── Scope middleware ──────────────────────────────────────────────────────
/**
* Installs an extension scoped to this namespace.
* The extension registers routes and services on this scope only.
* Adds middlewares applied to every route in this scope.
* Combined at compile time in order: Global → Scope → Annotation → Explicit.
*/
public FlashScope use(Middleware... middlewares) {
flushPending();
scopeMiddlewares.addAll(Arrays.asList(middlewares));
return this;
}
// ── Extension installation ────────────────────────────────────────────────
@Override
public FlashScope install(FlashExtension ext) {
flushPending();
@@ -89,128 +75,73 @@ public final class FlashScope implements FlashRegistrar {
return this;
}
// ── FlashRegistrar — route registration ───────────────────────────────────
// ── Route registration (deferred) ─────────────────────────────────────────
@Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
@Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PURGE, path, h); }
private RouteHandle<FlashScope> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
return track(new RouteHandle<>(this, m -> {
String full = ns(path);
emit(method, full, null, List.of(), m);
router.doRegister(method, full, h, m);
}));
private RouteHandle<FlashScope> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
String full = ns(path);
Middleware[] scopeMw = snapshotScopeMiddlewares();
return track(new RouteHandle<>(this, mw ->
deferredRoutes.add(new RouteDefinition(method, full, new SimpleHandler(h), scopeMw, mw, false, ctx, namespace))));
}
/**
* Begins registration of a class-based handler. Annotation processors from the
* parent app and any installed on this scope are applied. The {@link Route @Route}
* path is prepended with this scope's namespace automatically.
*
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
* automatically before the next operation or when the scope consumer returns.
* Registers a class-based handler annotated with {@link Route @Route}.
* Not part of {@link FlashRegistrar} — scope-only convenience.
*/
@Override
public RouteHandle<FlashScope> register(RequestHandler handler) {
return track(new RouteHandle<>(this, explicit -> {
List<Middleware> injected = ctx.processors().stream()
.flatMap(p -> p.process(handler.getClass()).stream())
.toList();
Middleware[] all = injected.isEmpty()
? explicit
: Stream.concat(injected.stream(), Arrays.stream(explicit)).toArray(Middleware[]::new);
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null) {
String full = ns(annotation.path());
emit(annotation.method(), full, handler.getClass(), injected, explicit);
router.doRegister(annotation.method(), full, (RequestHandler) handler, all);
}
}));
RouteHandle<FlashScope> register(RequestHandler handler) {
Route ann = handler.getClass().getAnnotation(Route.class);
if (ann == null)
throw new InitializationException(
handler.getClass().getName() + " is missing @Route");
String full = ns(ann.path());
Middleware[] scopeMw = snapshotScopeMiddlewares();
return track(new RouteHandle<>(this, mw ->
deferredRoutes.add(new RouteDefinition(ann.method(), full, handler, scopeMw, mw, true, ctx, namespace))));
}
/**
* Scans {@code packageName} for {@link RequestHandler} subclasses annotated with
* {@link Route @Route}. Each is instantiated via its no-arg constructor and registered
* with this scope's namespace prefix and annotation processors applied.
*/
@Override
public FlashScope scan(String packageName) {
flushPending();
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this;
}
@Override
public FlashScope onException(AbstractRouter.ExceptionHandler h) {
flushPending();
router.onException(h);
return this;
}
public FlashContext ctx() { return ctx; }
@Override
public FlashScope onNotFound(SimpleHandler.FunctionalHandler h) {
flushPending();
router.onNotFound(h);
return this;
}
// ── Internals (called by FlashApp.mount) ──────────────────────────────────
@Override
public ExtensionContext ctx() {
return ctx;
}
void flush() { flushPending(); }
// ── Internals ─────────────────────────────────────────────────────────────
List<RouteDefinition> routes() { return deferredRoutes; }
/** Ensures any pending route is registered when the scope consumer returns. */
void flush() {
flushPending();
}
/** Returns the sub-router for GlobalRouter to mount. */
AbstractRouter router() {
return router;
}
/** Prepends this scope's namespace to the given path. */
private String ns(String path) {
return namespace + PathUtils.sanitize(path);
}
@SuppressWarnings("unchecked")
private Middleware[] snapshotScopeMiddlewares() {
return scopeMiddlewares.isEmpty() ? NO_MW : scopeMiddlewares.toArray(NO_MW);
}
private static RequestHandler instantiate(Class<?> cls) {
try {
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
/**
* Emits a {@link RouteEvent} to all {@link RouteListener}s visible from this scope's context.
* Parent-level listeners (registered on the app) are included via context inheritance.
* No-op if no listener has been registered. Never called on the request hot-path.
*/
@SuppressWarnings("unchecked")
private void emit(HttpMethod method, String path, Class<?> handlerClass,
List<Middleware> injected, Middleware[] explicit) {
List<RouteListener> listeners = ctx.routeListeners();
if (listeners.isEmpty()) return;
Middleware[] routerMws = router.routerMiddlewares();
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
RouteEvent event = new RouteEvent(method, path, namespace,
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
listeners.forEach(l -> l.onRoute(event));
}
}
@@ -1,5 +1,6 @@
package dev.relism.extension;
import dev.relism.exceptions.InitializationException;
import dev.relism.models.RequestHandler;
import dev.relism.routing.Route;
@@ -15,6 +16,10 @@ import java.util.jar.JarFile;
* Minimal classpath scanner used by {@link FlashApp#scan} and {@link FlashScope#scan}.
* Finds all classes in a package that extend {@link RequestHandler} and carry {@link Route @Route}.
* Supports both exploded directories (development) and fat JARs (deployment).
*
* <p><b>Fail-fast:</b> if the package does not exist, contains no handlers, or a handler
* class cannot be loaded, an {@link InitializationException} is thrown immediately.
* A clear crash at boot is always preferable to a server that starts "empty".
*/
final class PackageScanner {
@@ -22,55 +27,84 @@ final class PackageScanner {
/**
* Returns all {@link RequestHandler} subclasses in {@code packageName} that carry
* {@link Route @Route} and have a public no-arg constructor.
* {@link Route @Route}.
*
* @throws InitializationException if the package is empty, does not exist, or a
* handler class fails to load
*/
static List<Class<?>> findHandlers(String packageName) {
if (packageName == null || packageName.isBlank())
throw new InitializationException("scan() called with null or blank package name");
String resourcePath = packageName.replace('.', '/');
ClassLoader cl = Thread.currentThread().getContextClassLoader();
List<Class<?>> result = new ArrayList<>();
List<String> errors = new ArrayList<>();
boolean packageFound = false;
try {
Enumeration<URL> resources = cl.getResources(resourcePath);
while (resources.hasMoreElements()) {
packageFound = true;
URL url = resources.nextElement();
String protocol = url.getProtocol();
if ("file".equals(protocol)) {
scanDirectory(new File(url.toURI()), packageName, cl, result);
scanDirectory(new File(url.toURI()), packageName, cl, result, errors);
} else if ("jar".equals(protocol)) {
String jarPath = url.getPath();
// jar:file:/path/to/app.jar!/com/example → /path/to/app.jar
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
try (JarFile jar = new JarFile(filePart)) {
scanJar(jar, resourcePath, packageName, cl, result);
scanJar(jar, resourcePath, packageName, cl, result, errors);
}
}
}
} catch (InitializationException e) {
throw e; // re-throw our own exceptions
} catch (Exception e) {
throw new RuntimeException("Failed to scan package: " + packageName, e);
throw new InitializationException("Failed to scan package: " + packageName, e);
}
if (!packageFound)
throw new InitializationException(
"scan(\"" + packageName + "\") — package not found on classpath. " +
"Verify the package name and ensure the module is on the classpath.");
if (!errors.isEmpty())
throw new InitializationException(
"scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " +
String.join("\n • ", errors));
if (result.isEmpty())
throw new InitializationException(
"scan(\"" + packageName + "\") — no @Route handlers found. " +
"Ensure handler classes extend RequestHandler, carry @Route, are not abstract, " +
"and have a public no-arg constructor.");
return result;
}
private static void scanDirectory(File dir, String packageName, ClassLoader cl, List<Class<?>> result) {
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
List<Class<?>> result, List<String> errors) {
File[] files = dir.listFiles();
if (files == null) return;
for (File file : files) {
if (file.isDirectory()) {
scanDirectory(file, packageName + '.' + file.getName(), cl, result);
scanDirectory(file, packageName + '.' + file.getName(), cl, result, errors);
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
String className = packageName + '.' + file.getName().replace(".class", "");
tryLoad(className, cl, result);
tryLoad(className, cl, result, errors);
}
}
}
private static void scanJar(JarFile jar, String resourcePath, String packageName,
ClassLoader cl, List<Class<?>> result) {
ClassLoader cl, List<Class<?>> result, List<String> errors) {
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
String className = name.replace('/', '.').replace(".class", "");
tryLoad(className, cl, result);
tryLoad(className, cl, result, errors);
}
}
}
@@ -84,23 +118,34 @@ final class PackageScanner {
private static boolean isAnonymous(String fileName) {
int dollar = fileName.lastIndexOf('$');
if (dollar < 0) return false;
// skip past any extra '$' (lambda desugaring may produce '$$Lambda$...')
int next = dollar + 1;
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
}
private static void tryLoad(String className, ClassLoader cl, List<Class<?>> result) {
private static void tryLoad(String className, ClassLoader cl,
List<Class<?>> result, List<String> errors) {
try {
Class<?> cls = cl.loadClass(className);
if (RequestHandler.class.isAssignableFrom(cls)
&& cls.isAnnotationPresent(Route.class)
&& !java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) {
cls.getDeclaredConstructor(); // verify no-arg constructor exists
result.add(cls);
if (!RequestHandler.class.isAssignableFrom(cls)) return;
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
if (!cls.isAnnotationPresent(Route.class)) return;
// Verify no-arg constructor exists — fail-fast if missing
try {
cls.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
errors.add(className + " — missing public no-arg constructor");
return;
}
} catch (Exception | Error ignored) {
// Skip classes that cannot be loaded or don't meet criteria
result.add(cls);
} catch (ClassNotFoundException e) {
errors.add(className + " — class not found: " + e.getMessage());
} catch (NoClassDefFoundError e) {
errors.add(className + " — missing dependency: " + e.getMessage());
} catch (LinkageError e) {
errors.add(className + " — linkage error: " + e.getMessage());
}
}
}
@@ -0,0 +1,32 @@
package dev.relism.extension;
import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.routing.Middleware;
/**
* Immutable snapshot of a route captured during the builder phase.
*
* <p>Accumulated by {@link FlashApp} and {@link FlashScope}. All definitions —
* regardless of origin — are compiled into a single flat router at
* {@link FlashApp#start()}.
*
* @param method HTTP method
* @param path fully resolved path (namespace already prepended for scope routes)
* @param handler SimpleHandler for lambdas, user handler for class-based
* @param scopeMiddlewares middlewares from {@link FlashScope#use} (empty for app-level routes)
* @param explicitMiddlewares middlewares from {@link dev.relism.routing.RouteHandle#with}
* @param classBasedHandler true → needs annotation processing + context binding
* @param ctx the FlashContext for this route's binding and processors
* @param namespace logical namespace for route events ("/" for app, "/api" for scope, etc.)
*/
record RouteDefinition(
HttpMethod method,
String path,
RequestHandler handler,
Middleware[] scopeMiddlewares,
Middleware[] explicitMiddlewares,
boolean classBasedHandler,
FlashContext ctx,
String namespace
) {}
@@ -3,7 +3,7 @@ package dev.relism.extension;
/**
* Observer notified once for each route registered on a {@link FlashApp} or {@link FlashScope}.
*
* <p>Register via {@link ExtensionContext#addRouteListener}. The listener is called
* <p>Register via {@link FlashContext#addRouteListener}. The listener is called
* <em>once per route at boot time</em>, before the route is handed to the routing engine.
* There is zero overhead on the request hot-path.
*
@@ -10,6 +10,7 @@ import lombok.Value;
import lombok.experimental.NonFinal;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -43,24 +44,42 @@ public class Request {
@NonFinal @Setter PathParams pathParams;
@NonFinal @Setter QueryParams queryParams;
private Request(RequestLine requestLine, RequestBody body) {
this.requestLine = requestLine;
this.body = body;
this.pathParams = null;
this.queryParams = null;
/**
* Remote socket address of the connected client. Set once at connection time from
* {@link java.net.Socket#getRemoteSocketAddress()} — the {@link InetSocketAddress}
* object already exists in the JDK and is passed by reference: zero allocation,
* zero copy. {@code null} only in test-constructed requests.
*
* <p>Use {@link #remoteAddress()} to access it. String conversion
* ({@code .getAddress().getHostAddress()}) is deferred to the caller — lazy and
* only paid when actually needed.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
InetSocketAddress remoteAddress;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) {
this.requestLine = requestLine;
this.body = body;
this.pathParams = null;
this.queryParams = null;
this.remoteAddress = remoteAddress;
}
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */
public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body));
this(requestLine, RequestBody.of(body), null);
}
public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen) {
int bodyStart, int preBufLen,
InetSocketAddress remoteAddress) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb);
return new Request(requestLine, rb, remoteAddress);
}
// ── Request line ──────────────────────────────────────────────────────────
@@ -126,6 +145,22 @@ public class Request {
*/
public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
// ── Remote address ────────────────────────────────────────────────────────
/**
* Returns the remote socket address of the connected client, or {@code null}
* for test-constructed requests.
*
* <p>The {@link InetSocketAddress} is the JDK object created during
* {@link java.net.ServerSocket#accept()} — no allocation occurs here.
* To obtain the IP string (lazy, allocates once):
* <pre>{@code
* InetSocketAddress addr = req.remoteAddress();
* if (addr != null) String ip = addr.getAddress().getHostAddress();
* }</pre>
*/
public InetSocketAddress remoteAddress() { return remoteAddress; }
// ── Body ──────────────────────────────────────────────────────────────────
/**
@@ -1,17 +1,134 @@
package dev.relism.models;
import dev.relism.extension.FlashContext;
import java.util.Optional;
/**
* 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.
* via {@link dev.relism.extension.FlashApp#register} or {@link dev.relism.extension.FlashApp#scan}.
* For one-off routes, prefer the lambda DSL ({@code app.get(path, handler)}).
*
* <h3>Lifecycle</h3>
* <ol>
* <li>Instantiation — no-arg constructor (for scan) or manual {@code new Handler(...)}</li>
* <li>{@link #bind} — called once by the framework at {@code start()}, injects the
* {@link FlashContext} and invokes {@link #onInit()}</li>
* <li>{@link #handle} — called on every matching request (hot-path, zero-alloc)</li>
* </ol>
*
* <h3>Service access</h3>
* Override {@link #onInit()} to cache services from the {@link FlashContext}
* into private fields. This keeps the hot-path ({@code handle}) free of map lookups.
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/users")
* public class UserHandler extends RequestHandler {
* private UserService users;
*
* @Override protected void onInit() {
* users = require(UserService.class);
* }
*
* @Override public Object handle(Request req, Response res) {
* return users.findAll();
* }
* }
* }</pre>
*/
public abstract class RequestHandler {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first request.
* Injects the {@link FlashContext} and triggers {@link #onInit()}.
*
* <p><b>Infrastructure method</b> — do not call from user code.
* Use {@link dev.relism.extension.FlashApp#register} or
* {@link dev.relism.extension.FlashApp#scan} instead.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
/**
* Override to cache services at boot time. Called once after {@link #bind},
* before any request reaches this handler.
*
* <p>Use {@link #require} and {@link #find} to retrieve services from the
* {@link FlashContext}. Cache them in private fields so the hot-path
* ({@link #handle}) has zero lookup overhead.
*
* <p><b>Important:</b> if your class extends another handler base (e.g.
* {@code JacksonHandler}), call {@code super.onInit()} first so the parent
* can initialise its own services.
*
* <pre>{@code
* @Override protected void onInit() {
* super.onInit();
* myService = require(MyService.class);
* }
* }</pre>
*/
protected void onInit() {}
/**
* Retrieves a required service from the {@link FlashContext}.
* Throws {@link IllegalStateException} if the service is not registered or
* this handler has not been bound yet.
*
* <p>Typically called inside {@link #onInit()} to cache the result.
*
* @param type the service class
* @param <T> the service type
* @return the service instance, never null
*/
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
/**
* Looks up an optional service from the {@link FlashContext}.
*
* @param type the service class
* @param <T> the service type
* @return the service, or empty if not registered
*/
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
/**
* Looks up an optional service from the {@link FlashContext}.
* Identical to {@link #find} — prefer this name for expressive call sites
* ({@code optional(ViewEngine.class).ifPresent(...)}).
*
* @param type the service class
* @param <T> the service type
* @return the service, or empty if not registered
*/
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via FlashApp.register() or FlashApp.scan(), not directly on the router");
}
/**
* 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) throws Exception;
}
}
@@ -1,53 +1,24 @@
package dev.relism.routing;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.ContentType;
import dev.relism.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import dev.relism.models.*;
import dev.relism.template.ErrorPages;
import lombok.AccessLevel;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
/**
* Base router. Each router has a namespace prefix (default {@code "/"}).
* Error handlers are scoped to this router.
* Base router contract. Owns error handlers and the compile-time middleware
* wrapping logic. The only concrete implementation is
* {@link dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl}.
*
* <p>Router-level {@link Middleware middlewares} are passed at construction time and
* pre-fused into a single wrapper applied to every handler registered on this router.
* Handler-level middlewares are passed to {@link #doRegister} and wrapped
* <em>inside</em> the router-level chain.
* <p>All middleware composition happens once at boot time; the hot-path sees
* only a plain {@link RequestHandler} call with zero allocation overhead.
*
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain
* {@link RequestHandler} call with zero allocation and zero lookup overhead.
*
* <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} or
* {@link dev.relism.extension.FlashScope} — they are the public registration API.
* {@link #doRegister} is an infrastructure method for those entry points.
* <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} — the single
* public registration API. {@link #doRegister} is an infrastructure method.
*/
public abstract class AbstractRouter {
@Getter
protected String namespace = "/";
@Getter(AccessLevel.PACKAGE)
protected byte[] namespaceBytes = new byte[]{ '/' };
/**
* Pre-fused router-level middleware, or {@code null} when none were registered.
* A single null-check in {@link #compile} is the only cost when no router middleware exists.
*/
private final Middleware routerMiddleware;
/**
* Raw router-level middleware array, kept for route event emission by
* {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}.
* Never mutated after construction. Not used on the hot-path.
*/
private final Middleware[] rawRouterMiddlewares;
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
res.setStatusCode(404);
res.setContentType(ContentType.TEXT_HTML);
@@ -60,113 +31,44 @@ public abstract class AbstractRouter {
return ErrorPages.renderException(req, ex);
};
/**
* Constructs a router with optional router-level middlewares.
* Middlewares are pre-fused at construction time; the first element executes outermost.
*
* @param middlewares zero or more middlewares applied to every handler on this router
*/
protected AbstractRouter(Middleware... middlewares) {
this.rawRouterMiddlewares = middlewares;
this.routerMiddleware = middlewares.length == 0 ? null
: middlewares.length == 1 ? middlewares[0]
: Middleware.of(middlewares);
}
public SimpleHandler getNotFoundHandler() { return notFoundHandler; }
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
/**
* Returns the raw router-level middleware array as passed at construction.
* Used by {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}
* to populate {@link dev.relism.extension.RouteEvent#middlewareChain()}.
* Never mutated; never called on the hot-path.
*/
public Middleware[] routerMiddlewares() { return rawRouterMiddlewares; }
// ── Internal wiring ───────────────────────────────────────────────────────
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
void setNamespace(String namespace) {
this.namespace = namespace;
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
}
/**
* Compiles a handler with its middleware chain. Called once per handler at registration.
*
* <p>Application order (innermost → outermost):
* <ol>
* <li>handler-level middlewares (passed at the call site)</li>
* <li>router-level middleware (set in the constructor)</li>
* </ol>
*/
private RequestHandler compile(RequestHandler handler, Middleware[] handlerMiddlewares) {
RequestHandler compiled = handler;
for (int i = handlerMiddlewares.length - 1; i >= 0; i--)
compiled = new SimpleHandler(handlerMiddlewares[i].wrap(compiled));
if (routerMiddleware != null)
compiled = new SimpleHandler(routerMiddleware.wrap(compiled));
return compiled;
}
// ── Error handler configuration ───────────────────────────────────────────
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
public void onNotFound(SimpleHandler.FunctionalHandler handler) {
this.notFoundHandler = new SimpleHandler(handler);
return this;
}
public AbstractRouter onException(ExceptionHandler handler) {
public void onException(ExceptionHandler handler) {
this.exceptionHandler = handler;
return this;
}
// ── Infrastructure registration ──────────────────────────────────────────
// Used by FlashApp and FlashScope. Not part of the public user-facing API.
// ── Infrastructure registration ──────────────────────────────────────────
// Used by FlashApp at compile time. Not part of the user-facing API.
/**
* Registers a lambda handler immediately with a pre-built middleware array.
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
*/
public AbstractRouter doRegister(HttpMethod method, String path,
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path),
compile(new SimpleHandler(handler), middlewares));
}
/**
* Registers a class-based handler immediately with a pre-built middleware array.
* Reads {@link Route @Route} for method and path.
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
*/
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null)
addRoute(annotation.method(), annotation.path(),
compile(handler, middlewares));
return this;
}
/**
* Registers a class-based handler with an explicit method and path (ignoring the
* {@link Route @Route} annotation's path). Used by {@link dev.relism.extension.FlashScope}
* to prepend the scope's namespace prefix.
* Infrastructure method — use {@link dev.relism.extension.FlashScope} instead.
* Registers a handler with a pre-built middleware array.
* Compiles the middleware chain once at boot — zero overhead on hot-path.
*/
public AbstractRouter doRegister(HttpMethod method, String path,
RequestHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
}
// ── Routing ───────────────────────────────────────────────────────────────
/**
* Compiles handler-level middleware into a single wrapped handler.
* Called once per route at registration time.
*/
private RequestHandler compile(RequestHandler handler, Middleware[] middlewares) {
RequestHandler compiled = handler;
for (int i = middlewares.length - 1; i >= 0; i--)
compiled = new SimpleHandler(middlewares[i].wrap(compiled));
return compiled;
}
// ── Routing ──────────────────────────────────────────────────────────────
public abstract RequestHandler route(Request request);
/**
* Stores a pre-compiled handler in the underlying routing structure.
* The handler passed here is already fully wrapped — implementations must not
* apply any additional middleware logic.
*/
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
@@ -1,75 +0,0 @@
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. Routes to the longest-matching mounted sub-router first,
* falling back to the internal {@link FastPathRouterImpl}.
*/
public class GlobalRouter extends AbstractRouter {
private final Map<String, AbstractRouter> subRoutersMap = new HashMap<>();
private final List<AbstractRouter> sortedSubRouters = new ArrayList<>();
private final AbstractRouter internalRouter;
public GlobalRouter(Middleware... middlewares) {
super(middlewares);
this.internalRouter = new FastPathRouterImpl();
}
public GlobalRouter() { this(new Middleware[0]); }
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;
}
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;
}
}
@@ -9,8 +9,6 @@ import dev.relism.http.HttpMethod;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.Middleware;
import dev.relism.routing.PathUtils;
/**
* Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
@@ -23,8 +21,7 @@ public class FastPathRouterImpl extends AbstractRouter {
private volatile FastPathRouter<ByteView, RequestHandler> router;
private String[] cachedParamNames;
public FastPathRouterImpl(Middleware... middlewares) { super(middlewares); }
public FastPathRouterImpl() { super(); }
public FastPathRouterImpl() {}
private static final class FastPathRouterContext {
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
@@ -43,8 +40,7 @@ public class FastPathRouterImpl extends AbstractRouter {
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
String fullPath = PathUtils.join(namespace, path);
builder.add(StringRouteParser.parse(method.name() + fullPath), handler);
builder.add(StringRouteParser.parse(method.name() + path), handler);
this.router = null;
return this;
}