weeks of bullshit

This commit is contained in:
Relism
2026-04-17 08:18:11 +02:00
parent b5d4481502
commit 9efbe38c0c
157 changed files with 5171 additions and 1273 deletions
@@ -49,17 +49,16 @@ final class ChunkedInputStream extends InputStream {
}
private int readChunkSize() throws IOException {
int size = 0, b;
long size = 0;
int b;
while ((b = src.read()) != -1) {
if (b >= '0' && b <= '9') size = size * 16 + (b - '0');
else if (b >= 'a' && b <= 'f') size = size * 16 + (b - 'a' + 10);
else if (b >= 'A' && b <= 'F') size = size * 16 + (b - 'A' + 10);
else { // ';' (extensions) or '\r' — skip to end of line
while ((b = src.read()) != -1 && b != '\n');
break;
}
if (b >= '0' && b <= '9') size = (size << 4) | (b - '0');
else if (b >= 'a' && b <= 'f') size = (size << 4) | (b - 'a' + 10);
else if (b >= 'A' && b <= 'F') size = (size << 4) | (b - 'A' + 10);
else { while ((b = src.read()) != -1 && b != '\n'); break; } // ext or \r\n
if (size > Integer.MAX_VALUE) throw new IOException("Chunk size exceeds 2 GB limit");
}
return size;
return (int) size;
}
// Reads and discards trailer headers until the empty line that terminates the chunked body.
+33 -3
View File
@@ -1,8 +1,38 @@
package dev.relism;
import lombok.NoArgsConstructor;
@NoArgsConstructor
/**
* Global Flash constants and runtime environment flags.
*
* <h3>Dev mode</h3>
* Set via JVM property {@code -Dflash.env=dev} or environment variable {@code FLASH_ENV=dev}.
* Checked once at class load — zero runtime overhead.
*
* <p>Dev mode enables:
* <ul>
* <li>HTML error pages with full stack traces (default: JSON generic responses in prod)</li>
* <li>Template cache disabled in {@code flash-ext-view}</li>
* </ul>
*
* <p>Extensions can read {@link #DEV} to branch behavior without re-implementing the detection:
* <pre>{@code
* if (Flash.DEV) log.debug("Verbose OIDC debug output enabled");
* }</pre>
*/
public final class Flash {
private Flash() {}
public static final String VERSION = "5.0.0-dev";
/**
* {@code true} when running in dev mode.
* Set via {@code -Dflash.env=dev} (JVM property) or {@code FLASH_ENV=dev} (env var).
* JVM property takes precedence.
*/
public static final boolean DEV;
static {
String prop = System.getProperty("flash.env");
DEV = "dev".equalsIgnoreCase(prop != null ? prop : System.getenv("FLASH_ENV"));
}
}
+84 -74
View File
@@ -19,7 +19,9 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
@@ -38,7 +40,7 @@ class HttpServer implements ServerHandle {
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 final AtomicReference<Thread> acceptThread = new AtomicReference<>();
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);
@@ -50,11 +52,9 @@ class HttpServer implements ServerHandle {
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];
static {
for (int i = 0; i < 10; i++)
DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8);
}
// Per-thread scratch buffers — allocated once per VT, reused for every request.
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
private static final ThreadLocal<byte[]> CHUNK_BUF = ThreadLocal.withInitial(() -> new byte[8192]);
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
this.configuration = configuration;
@@ -63,88 +63,95 @@ class HttpServer implements ServerHandle {
}
@Override
public CompletableFuture<Void> start() {
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
return readyFuture;
public void start() {
acceptThread.set(Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run));
}
@Override
public void startAndBlock() {
start();
try { acceptThread.get().join(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
private void run() {
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);
while (!stopped) {
try {
process(serverSocket.accept());
} catch (IOException e) {
if (!stopped) log.error("Accept loop error", e);
}
}
}
@Override
public CompletableFuture<Void> stop() {
stopped = true;
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 {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
return CompletableFuture.runAsync(() -> {
stopped = true;
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 {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
return CompletableFuture.completedFuture(null);
Thread.currentThread().interrupt();
}
});
}
// ── Hot-path ─────────────────────────────────────────────────────────────
private void process(Socket socket) {
activeSockets.add(socket);
executorService.submit(() -> {
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress());
while (!stopped) {
Request request = parser.parse(in);
if (request == null) break;
try {
executorService.submit(() -> {
activeSockets.add(socket);
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress());
while (!stopped) {
Request request = parser.parse(in);
if (request == null) break;
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler();
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);
} catch (Exception ex) {
Object result = router.getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
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 = 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;
}
writeResponse(out, response, keepAlive);
request.drain();
if (!keepAlive) break;
} catch (IOException e) {
if (!stopped) {
if (e instanceof java.net.SocketException)
log.debug("Connection closed: {}", e.getMessage());
else
log.error("I/O error handling request", e);
}
} finally {
activeSockets.remove(socket);
}
} catch (IOException e) {
if (!stopped) {
if (e instanceof java.net.SocketException)
log.debug("Connection closed: {}", e.getMessage());
else
log.error("I/O error handling request", e);
}
} finally {
activeSockets.remove(socket);
}
});
});
} catch (RejectedExecutionException ignored) {
// Executor already shut down — close the socket so the client isn't left hanging.
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
}
}
private static boolean isKeepAlive(Request request) {
@@ -202,15 +209,18 @@ class HttpServer implements ServerHandle {
}
private static void writeLong(OutputStream out, long value) throws IOException {
if (value == 0) { out.write(DIGITS[0]); return; }
if (value < 0) { out.write('-'); value = -value; }
long divisor = 1;
while (value / divisor >= 10) divisor *= 10;
while (divisor > 0) { out.write(DIGITS[(int) ((value / divisor) % 10)]); divisor /= 10; }
if (value == 0) { out.write('0'); return; }
byte[] buf = LONG_BUF.get();
int pos = buf.length;
boolean neg = value < 0;
if (neg) value = -value;
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
if (neg) buf[--pos] = '-';
out.write(buf, pos, buf.length - pos);
}
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
byte[] buf = new byte[8192];
byte[] buf = CHUNK_BUF.get();
int n;
while ((n = stream.read(buf)) > 0) {
writeHex(out, n);
@@ -13,7 +13,16 @@ import java.util.concurrent.CompletableFuture;
*/
public interface ServerHandle {
CompletableFuture<Void> start();
/** Starts the accept loop on a background platform thread. Returns immediately. */
void start();
/**
* Starts the accept loop and blocks the calling thread until the server is stopped.
* Suitable for use in {@code main()} when no other work is needed after startup.
*/
void startAndBlock();
/** Gracefully stops the server, draining active connections. */
CompletableFuture<Void> stop();
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
@@ -9,9 +9,9 @@ import java.util.List;
* Inspects a handler class at registration time and returns zero or more
* {@link Middleware middlewares} to inject automatically.
*
* <p>Processors are called once per {@link FlashApp#register} call, before
* <p>Processors are called once per register call, before
* the handler is compiled into the router. Returning an empty list is always
* valid processors may also use the call purely for side effects
* valid: processors may also use the call purely for side effects
* (e.g. collecting OpenAPI metadata).
*
* <p>Register processors via {@link FlashContext#addAnnotationProcessor}.
@@ -0,0 +1,32 @@
package dev.relism.extension;
/**
* Semantic execution phases for {@link FlashExtension#priority()}.
*
* <p>Phase determines the order in which annotation-processor middlewares are injected into
* the chain. Lower value = runs earlier (outermost wrapper = first at request time).
*
* <pre>
* Request ──► EARLY middlewares ──► DEFAULT middlewares ──► LATE middlewares ──► handler
* </pre>
*
* <p>Within the same phase, extensions execute in install order (sort is stable).
* Raw integers are valid for fine-grained ordering within a phase
* (e.g. {@code ExtensionPhase.EARLY.value + 10}).
*/
public enum ExtensionPhase {
/** Security guards, rate limiting — must short-circuit before expensive processing. */
EARLY(100),
/** Normal application extensions. Default when {@link FlashExtension#priority()} is not overridden. */
DEFAULT(500),
/** Observability, logging, diagnostics — must observe after all business logic. */
LATE(900);
/** The integer priority value used for sorting. */
public final int value;
ExtensionPhase(int value) { this.value = value; }
}
@@ -1,5 +1,6 @@
package dev.relism.extension;
import dev.relism.Flash;
import dev.relism.ServerHandle;
import dev.relism.exceptions.InitializationException;
import dev.relism.http.HttpMethod;
@@ -7,155 +8,80 @@ import dev.relism.models.RequestHandler;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.Middleware;
import dev.relism.routing.Route;
import dev.relism.routing.RouteHandle;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
/**
* Single entry point for Flash. Owns one flat {@link FastPathRouterImpl} —
* all routes (app-level and scoped) compile into a single FSM at {@link #start()}.
*
* <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>
* all routes (app-level and scoped) compile into a single FSM at startup.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .use(cors)
* .get("/ping", (req, res) -> "pong")
* .get("/ping", (req, res) -> "pong")
* .get("/secured", (req, res) -> user(), oidc.protect())
* .scan("dev.example.handlers")
* .mount("/api", scope -> scope.get("/health", (req, res) -> "ok"))
* .start();
* .startAndBlock(); // blocks main thread
* }</pre>
*
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
* <ol>
* <li>Extensions sorted by {@link FlashExtension#priority()} — lower first.</li>
* <li>All {@link FlashExtension#provide} — register services, processors, listeners.</li>
* <li>{@link FlashContext#resolveAll()} — topo-sort, cycle detection.</li>
* <li>All {@link FlashExtension#routes} — routes registered, services available.</li>
* <li>Compile all routes into one flat FSM — zero prefix scanning at runtime.</li>
* <li>Accept loop started.</li>
* </ol>
*
* <h3>Default error handlers</h3>
* Dev mode ({@code -Dflash.env=dev}): rich HTML with stack traces.
* Prod mode (default): generic JSON — no internal details leaked.
* Override via {@link #onException} / {@link #onNotFound}.
*/
public final class FlashApp implements FlashRegistrar {
@Slf4j
public final class FlashApp extends FlashRegistrar<FlashApp> {
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<>();
private RouteHandle<?> pending;
private final AbstractRouter router = new FastPathRouterImpl();
private final ServerHandle server;
private final FlashContext ctx = new FlashContext();
private final List<FlashExtension> extensions = new ArrayList<>();
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private int port;
private FlashApp(FlashConfiguration config) {
try {
this.server = ServerHandle.create(config, router);
} catch (IOException e) {
throw new InitializationException("Failed to bind on port " + config.getPort(), e);
this.port = config.getPort();
}
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
}
// ── Factories ─────────────────────────────────────────────────────────────
public static FlashApp create(int port) {
return create(FlashConfiguration.builder().port(port).build());
}
public static FlashApp create(int port) { return create(FlashConfiguration.builder().port(port).build()); }
public static FlashApp create(FlashConfiguration cfg) { return new FlashApp(cfg); }
public static FlashApp create(FlashConfiguration config) {
return new FlashApp(config);
}
// ── Pending flush ─────────────────────────────────────────────────────────
private void flushPending() {
if (pending != null) { pending.ensureRegistered(); pending = null; }
}
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
flushPending();
pending = handle;
return handle;
}
// ── Extension installation ────────────────────────────────────────────────
@Override
public FlashApp install(FlashExtension ext) {
flushPending();
ext.install(this, ctx);
return this;
}
// ── Global middleware ─────────────────────────────────────────────────────
// ── Namespace mounting ────────────────────────────────────────────────────
/**
* 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();
globalMiddlewares.addAll(Arrays.asList(middlewares));
return this;
}
// ── Route registration (deferred) ─────────────────────────────────────────
@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); }
private static final Middleware[] NO_MW = new Middleware[0];
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, "/"))));
}
/**
* Registers a class-based handler annotated with {@link Route @Route}.
* App-level only — not part of {@link FlashRegistrar}.
*/
public RouteHandle<FlashApp> register(RequestHandler handler) {
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, "/"))));
}
@Override
public FlashApp scan(String packageName) {
flushPending();
PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this;
}
// ── Namespace mounting (syntactic sugar — routes go into same flat router) ─
/**
* 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.
* Mounts a scoped group of routes under {@code namespace}. Routes accumulate in a
* {@link FlashScope} builder and merge into this app's single flat router at startup —
* pure syntactic sugar, no extra router involved.
*/
public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
flushPending();
FlashScope scope = new FlashScope(namespace, ctx);
configure.accept(scope);
scope.flush();
deferredRoutes.addAll(scope.routes());
return this;
}
@@ -163,40 +89,87 @@ public final class FlashApp implements FlashRegistrar {
// ── Error handlers ────────────────────────────────────────────────────────
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
flushPending();
router.onException(handler);
return this;
}
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
flushPending();
router.onNotFound(handler);
return this;
}
@Override
public FlashContext ctx() { return ctx; }
@Override public FlashContext ctx() { return ctx; }
// ── Extensions ────────────────────────────────────────────────────────────
/**
* Registers an extension for two-phase installation at startup.
* Install order is irrelevant — all {@link FlashExtension#provide} calls complete
* before any {@link FlashExtension#routes} call begins.
* Extensions are sorted by {@link FlashExtension#priority()} before execution.
*/
public FlashApp install(FlashExtension ext) {
extensions.add(ext);
return this;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
/**
* Compiles all deferred routes into the flat FSM router, then starts
* the HTTP transport. One pass, one router, zero prefix scanning.
* Boots the server and starts accepting connections. Returns immediately — the
* accept loop runs on a background platform thread.
*
* <p>Use {@link #startAndBlock()} in {@code main()} if you have no other work to do
* after startup.
*
* @return {@code this} for storing or chaining (e.g. {@code .start().ctx().require(...)})
*/
public CompletableFuture<Void> start() {
flushPending();
compile();
return server.start();
public FlashApp start() {
boot();
server.start();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
return this;
}
/**
* Boots the server and blocks the calling thread until the server is stopped.
* Suitable for the end of {@code main()} — no need to keep the JVM alive manually.
*/
public void startAndBlock() {
boot();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
server.startAndBlock();
}
public CompletableFuture<Void> stop() { return server.stop(); }
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
deferredRoutes.add(new RouteDefinition(
method, path, handler, List.of(), mw,
!(handler instanceof SimpleHandler), ctx, "/"));
}
@Override
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
// ── Boot ─────────────────────────────────────────────────────────────────
private void boot() {
extensions.sort(Comparator.comparingInt(FlashExtension::priority));
extensions.forEach(e -> e.provide(ctx));
ctx.resolveAll();
extensions.forEach(e -> e.routes(this, ctx));
compile();
}
// ── Compilation ──────────────────────────────────────────────────────────
/**
* Compiles all deferred routes. Middleware chain order:
* Global → Scope → Annotation (class-based only) → Explicit (.with).
*/
private static final Middleware[] EMPTY_MW = new Middleware[0];
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
private void compile() {
for (RouteDefinition def : deferredRoutes) {
List<Middleware> injected;
@@ -208,52 +181,33 @@ public final class FlashApp implements FlashRegistrar {
} else {
injected = List.of();
}
Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(),
injected, def.explicitMiddlewares());
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) {
private void emitEvent(RouteDefinition def, Middleware[] chain) {
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());
List<Class<? extends Middleware>> mwClasses = new ArrayList<>(chain.length);
for (Middleware m : chain) mwClasses.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));
RouteEvent event = new RouteEvent(def.method(), def.path(), def.namespace(), "FlashApp", handlerClass, List.copyOf(mwClasses));
listeners.forEach(l -> l.onRoute(event));
}
// ── Internals ─────────────────────────────────────────────────────────────
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;
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
List<Middleware> injected, List<Middleware> explicit) {
int total = global.size() + scope.size() + injected.size() + explicit.size();
if (total == 0) return EMPTY_MW;
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);
for (Middleware m : explicit) all[i++] = m;
return all;
}
private static RequestHandler instantiate(Class<?> cls) {
try {
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
}
@@ -1,84 +1,137 @@
package dev.relism.extension;
import java.util.*;
import java.util.stream.Stream;
import java.util.function.Supplier;
/**
* 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:
* Three capabilities:
* <ol>
* <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>Service registry</b> — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.</li>
* <li><b>Annotation processors</b> — middleware injection from handler annotations at boot.</li>
* <li><b>Route listeners</b> — boot-time observation of the route graph.</li>
* </ol>
*
* <h3>Eager vs lazy registration</h3>
* <ul>
* <li>{@link #provide(Class, Object)} — instance is already constructed, registered immediately.</li>
* <li>{@link #supply(Class, Supplier)} — factory is registered; it runs once, at
* {@link #resolveAll()} time (called by {@link FlashApp#start()}) after all
* {@link FlashExtension#provide} phases complete. The factory may call
* {@link #require} for its own dependencies — the runtime resolves in topological
* order automatically and reports circular dependencies with the full cycle path.</li>
* </ul>
*
* <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 FlashContext {
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<>();
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
private final Map<Class<?>, Supplier<?>> pending = new LinkedHashMap<>();
private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>();
public FlashContext() {
this.parent = null;
}
// Lazy caches — nulled whenever the corresponding list is mutated.
private List<AnnotationProcessor> cachedProcessors;
private List<RouteListener> cachedListeners;
private FlashContext(FlashContext parent) {
this.parent = parent;
}
// DFS stack — tracks in-progress resolutions to detect circular dependencies.
private final LinkedHashSet<Class<?>> resolutionStack = new LinkedHashSet<>();
public FlashContext() { this.parent = null; }
private FlashContext(FlashContext parent) { this.parent = parent; }
/** Creates a child context that inherits this context's services and processors. */
public FlashContext child() {
return new FlashContext(this);
}
public FlashContext child() { return new FlashContext(this); }
// ── Service registry ─────────────────────────────────────────────────────
/** Stores {@code instance} under {@code type} for retrieval via {@link #require} or {@link #find}. */
/** Registers an already-constructed {@code instance} under {@code type}. */
public <T> void provide(Class<T> type, T instance) {
registry.put(type, instance);
}
/**
* Retrieves the service registered under {@code type}.
* Checks own scope first, then the parent chain.
* Registers a lazy factory for {@code type}. The factory runs once at
* {@link #resolveAll()} time (or on the first {@link #require} call for this type)
* and may call {@link #require} for its own dependencies — topological order
* is resolved automatically.
*
* @throws IllegalStateException if not found
* <pre>{@code
* ctx.supply(JwtValidator.class, () ->
* new JwtValidator(ctx.require(OidcProviderMetadata.class).jwksUri()));
* }</pre>
*/
@SuppressWarnings("unchecked")
public <T> T require(Class<T> type) {
T val = (T) registry.get(type);
if (val == null && parent != null) val = parent.find(type).orElse(null);
if (val == null)
throw new IllegalStateException(
"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 provided in this scope or any parent. */
@SuppressWarnings("unchecked")
public <T> Optional<T> find(Class<T> type) {
T val = (T) registry.get(type);
if (val != null) return Optional.of(val);
return parent != null ? parent.find(type) : Optional.empty();
public <T> void supply(Class<T> type, Supplier<T> factory) {
pending.put(type, factory);
}
/**
* Returns the service registered under {@code type} as an {@link Optional},
* or {@link Optional#empty()} if not present in this scope or any parent.
* Returns the service for {@code type}. Checks own scope first, then parent chain.
* Lazy-registered types are resolved on first access. Circular dependencies throw
* {@link IllegalStateException} with the full cycle path.
*
* <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.
* @throws IllegalStateException if the service is not found anywhere in the context chain
*/
public <T> Optional<T> optional(Class<T> type) {
return find(type);
@SuppressWarnings("unchecked")
public <T> T require(Class<T> type) {
Object val = registry.get(type);
if (val != null) return (T) val;
if (pending.containsKey(type)) return resolve(type);
if (parent != null) return parent.require(type);
throw new IllegalStateException(
"Service not found: " + type.getSimpleName() +
" — register it via FlashContext.provide()/supply() or install the required extension");
}
/** Returns the service for {@code type}, or empty if not found in this scope or any parent. */
@SuppressWarnings("unchecked")
public <T> Optional<T> find(Class<T> type) {
Object val = registry.get(type);
if (val != null) return Optional.of((T) val);
if (pending.containsKey(type)) return Optional.of(resolve(type));
return parent != null ? parent.find(type) : Optional.empty();
}
/** Alias for {@link #find} — prefer when semantics are "this may or may not exist". */
public <T> Optional<T> optional(Class<T> type) { return find(type); }
/**
* Eagerly resolves all pending lazy suppliers in topological order.
* Called once by {@link FlashApp#start()} after all {@link FlashExtension#provide}
* phases complete. Any circular dependency is reported with the full cycle path.
*/
void resolveAll() {
new ArrayList<>(pending.keySet()).forEach(this::resolve);
}
@SuppressWarnings("unchecked")
private <T> T resolve(Class<?> type) {
Object already = registry.get(type);
if (already != null) return (T) already; // resolved during an earlier DFS branch
if (!resolutionStack.add(type)) {
// type is already on the current DFS path → circular dependency
List<Class<?>> cycle = new ArrayList<>(resolutionStack);
cycle.add(type);
StringBuilder msg = new StringBuilder("Circular dependency: ");
for (int i = 0; i < cycle.size(); i++) {
if (i > 0) msg.append("");
msg.append(cycle.get(i).getSimpleName());
}
throw new IllegalStateException(msg.toString());
}
Supplier<?> factory = pending.get(type);
Object instance = factory.get(); // recursive require() calls happen here
registry.put(type, instance);
pending.remove(type);
resolutionStack.remove(type);
return (T) instance;
}
// ── Annotation processors ────────────────────────────────────────────────
@@ -86,14 +139,19 @@ public class FlashContext {
/** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
public void addAnnotationProcessor(AnnotationProcessor processor) {
processors.add(processor);
cachedProcessors = null;
}
/** All processors visible from this context: parent-first, then own. */
/** All processors visible from this context: parent-first, then own. Cached after first call. */
List<AnnotationProcessor> processors() {
if (parent == null) return Collections.unmodifiableList(processors);
List<AnnotationProcessor> parentProcessors = parent.processors();
if (processors.isEmpty()) return parentProcessors;
return Stream.concat(parentProcessors.stream(), processors.stream()).toList();
if (cachedProcessors != null) return cachedProcessors;
if (parent == null) return cachedProcessors = List.copyOf(processors);
List<AnnotationProcessor> p = parent.processors();
if (processors.isEmpty()) return cachedProcessors = p;
List<AnnotationProcessor> merged = new ArrayList<>(p.size() + processors.size());
merged.addAll(p);
merged.addAll(processors);
return cachedProcessors = List.copyOf(merged);
}
// ── Route listeners ──────────────────────────────────────────────────────
@@ -101,13 +159,18 @@ public class FlashContext {
/** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
public void addRouteListener(RouteListener listener) {
routeListeners.add(listener);
cachedListeners = null;
}
/** All route listeners visible from this context: parent-first, then own. */
/** All route listeners visible from this context: parent-first, then own. Cached after first call. */
List<RouteListener> routeListeners() {
if (parent == null) return Collections.unmodifiableList(routeListeners);
List<RouteListener> parentListeners = parent.routeListeners();
if (routeListeners.isEmpty()) return parentListeners;
return Stream.concat(parentListeners.stream(), routeListeners.stream()).toList();
if (cachedListeners != null) return cachedListeners;
if (parent == null) return cachedListeners = List.copyOf(routeListeners);
List<RouteListener> p = parent.routeListeners();
if (routeListeners.isEmpty()) return cachedListeners = p;
List<RouteListener> merged = new ArrayList<>(p.size() + routeListeners.size());
merged.addAll(p);
merged.addAll(routeListeners);
return cachedListeners = List.copyOf(merged);
}
}
@@ -1,32 +1,70 @@
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 FlashContext}.
* Two-phase contract for all Flash extensions.
*
* <p>Extensions work identically whether installed at the top-level app or inside a
* mounted scope:
* <p>Extension lifecycle inside {@link FlashApp#start()}:
* <ol>
* <li>Extensions are sorted by {@link #priority()} — lower value runs first.</li>
* <li><b>Provide phase</b> — {@link #provide(FlashContext)} is called for <em>all</em>
* installed extensions. Use this phase to register services, annotation processors,
* and route listeners. Never call {@link FlashContext#require} here.</li>
* <li>Context resolution — {@link FlashContext#resolveAll()} performs topological
* resolution of lazy suppliers. Circular or missing dependencies fail here with
* a clear message before any request is served.</li>
* <li><b>Routes phase</b> — {@link #routes(FlashRegistrar, FlashContext)} is called for
* all extensions. All services are resolved; {@link FlashContext#require} is safe.</li>
* </ol>
*
* <h3>Priority and middleware ordering</h3>
* {@link #priority()} controls the order annotation processors are registered, which
* determines the annotation-layer middleware chain position:
* <pre>
* Request ──► EARLY processors' mw ──► DEFAULT processors' mw ──► LATE processors' mw ──► handler
* </pre>
* Use {@link ExtensionPhase} constants for semantic ordering:
* <pre>{@code
* public class RateLimitExtension implements FlashExtension {
* public void install(FlashRegistrar app, FlashContext ctx) {
* RateLimiter limiter = new RateLimiter(100);
* ctx.provide(RateLimiter.class, limiter);
* app.get("/rate-info", (req, res) -> limiter.info());
* @Override public int priority() { return ExtensionPhase.EARLY.value; }
* }</pre>
*
* <h3>Example</h3>
* <pre>{@code
* public class MetricsExtension implements FlashExtension {
*
* @Override public int priority() { return ExtensionPhase.LATE.value; }
*
* @Override
* public void provide(FlashContext ctx) {
* ctx.provide(MetricsRegistry.class, new PromMetricsRegistry());
* }
*
* @Override
* public void routes(FlashRegistrar<?> app, FlashContext ctx) {
* app.get("/metrics", (req, res) -> ctx.require(MetricsRegistry.class).scrape());
* }
* }
*
* // Top-level app
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .install(new OidcExtension(OidcConfig.fromEnv()));
*
* // Scoped
* app.mount("/api", scope -> scope.install(new RateLimitExtension()));
* }</pre>
*/
@FunctionalInterface
public interface FlashExtension {
void install(FlashRegistrar app, FlashContext ctx);
/**
* Phase 1 — register services and processors.
* Safe: {@link FlashContext#provide}, {@link FlashContext#supply},
* {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}.
* Unsafe: {@link FlashContext#require} (services not yet resolved).
*/
default void provide(FlashContext ctx) {}
/**
* Phase 2 — register routes. All services are fully resolved.
* {@link FlashContext#require} is safe here.
*/
default void routes(FlashRegistrar<?> app, FlashContext ctx) {}
/**
* Execution priority. Lower = earlier in the annotation middleware chain.
* Tie-breaking: same value → install order (sort is stable).
* Default: {@link ExtensionPhase#DEFAULT} (500).
*/
default int priority() { return ExtensionPhase.DEFAULT.value; }
}
@@ -1,47 +1,106 @@
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.RouteHandle;
import dev.relism.routing.Middleware;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
import java.util.List;
/**
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
* Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}.
*
* <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>All route methods register <em>immediately</em> — no deferred {@code .with()} call.
* Middleware is passed as a varargs third argument:
*
* <pre>{@code
* // In an extension:
* public void install(FlashRegistrar app, FlashContext ctx) {
* app.get("/health", (req, res) -> "ok");
* app.get("/secured", (req, res) -> user()).with(oidc.protect());
* }
* app.get("/ping", (req, res) -> "pong")
* app.get("/secured", (req, res) -> user(), oidc.protect())
* app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter)
* }</pre>
*
* <p>{@link FlashExtension#routes} receives a {@code FlashRegistrar<?>} for route
* registration. Extension installation ({@code install()}) is only available on
* {@link FlashApp} — scoped install is intentionally unsupported.
*
* @param <SELF> concrete registrar type — enables fluent chaining without casting
*/
public interface FlashRegistrar {
@SuppressWarnings("unchecked")
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
FlashRegistrar install(FlashExtension ext);
// ── HTTP method registration ──────────────────────────────────────────────
// ── Lambda route registration ────────────────────────────────────────────
public final SELF get (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.GET, path, h, mw); }
public final SELF post (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.POST, path, h, mw); }
public final SELF put (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PUT, path, h, mw); }
public final SELF delete (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.DELETE, path, h, mw); }
public final SELF patch (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PATCH, path, h, mw); }
public final SELF options(String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.OPTIONS, path, h, mw); }
public final SELF head (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.HEAD, path, h, mw); }
public final SELF trace (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.TRACE, path, h, mw); }
public final SELF connect(String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.CONNECT, path, h, mw); }
public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); }
RouteHandle<?> get (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> post (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> put (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> delete (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> patch (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> options(String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> head (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> trace (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> connect(String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> purge (String path, SimpleHandler.FunctionalHandler h);
// ── Middleware ────────────────────────────────────────────────────────────
/**
* 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.
* Adds middlewares to this registrar's own scope.
* On {@link FlashApp}: applied to every route on the app.
* On {@link FlashScope}: applied to every route in this scope only.
* Order-independent: middleware is resolved at {@link FlashApp#start()}.
*/
FlashRegistrar scan(String packageName);
public final SELF use(Middleware... middlewares) {
for (Middleware m : middlewares) addMiddleware(m);
return (SELF) this;
}
// ── Scan ─────────────────────────────────────────────────────────────────
/**
* Scans {@code packageName} for {@link RequestHandler} subclasses with a routing
* annotation ({@link Route @Route} or {@code @GET}, {@code @POST}, …).
* Each match is constructed with its public no-arg constructor and registered immediately.
*
* @throws InitializationException if the package is empty, not found, or any handler
* class fails to load or instantiate
*/
public final SELF scan(String packageName) {
PackageScanner.findHandlers(packageName).forEach(cls -> {
Route ann = Routes.of(cls);
addRoute(ann.method(), ann.path(), instantiate(cls), List.of());
});
return (SELF) this;
}
/** Returns the {@link FlashContext} for this registrar. */
FlashContext ctx();
public abstract FlashContext ctx();
// ── Infrastructure ────────────────────────────────────────────────────────
/**
* Adds a route immediately to this registrar's deferred compilation list.
* Subclasses may prepend a namespace prefix and inject scope middlewares before storing.
*/
protected abstract void addRoute(HttpMethod method, String path,
RequestHandler handler, List<Middleware> mw);
/** Registers a middleware in this registrar's own scope (global or scope-level). */
protected abstract void addMiddleware(Middleware mw);
private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, Middleware[] mw) {
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw));
return (SELF) this;
}
protected static RequestHandler instantiate(Class<?> cls) {
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
catch (Exception e) {
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
}
@@ -1,147 +1,58 @@
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.Middleware;
import dev.relism.routing.PathUtils;
import dev.relism.routing.Route;
import dev.relism.routing.RouteHandle;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 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()}.
* does not own a router. Routes merge into {@link FlashApp}'s single flat router
* at {@link FlashApp#start()}.
*
* <pre>{@code
* app.mount("/api", scope -> {
* scope.use(authMiddleware);
* scope.get("/health", (req, res) -> "ok"); // → GET /api/health
* scope.get("/health", (req, res) -> "ok"); // → GET /api/health
* scope.get("/users", (req, res) -> "...", rateLimiter); // → GET /api/users
* scope.scan("dev.example.api.handlers");
* });
* }</pre>
*/
public final class FlashScope implements FlashRegistrar {
public final class FlashScope extends FlashRegistrar<FlashScope> {
private final String namespace;
private final FlashContext ctx;
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];
private RouteHandle<?> pending;
FlashScope(String namespace, FlashContext parentCtx) {
this.namespace = namespace;
this.ctx = parentCtx.child();
}
// ── Pending flush ─────────────────────────────────────────────────────────
@Override public FlashContext ctx() { return ctx; }
private void flushPending() {
if (pending != null) { pending.ensureRegistered(); pending = null; }
}
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
flushPending();
pending = handle;
return handle;
}
// ── Scope middleware ──────────────────────────────────────────────────────
/**
* 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 ────────────────────────────────────────────────
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override
public FlashScope install(FlashExtension ext) {
flushPending();
ext.install(this, ctx);
return this;
}
// ── Route registration (deferred) ─────────────────────────────────────────
@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> 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))));
}
/**
* Registers a class-based handler annotated with {@link Route @Route}.
* Not part of {@link FlashRegistrar} — scope-only convenience.
*/
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))));
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
List<Middleware> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
deferredRoutes.add(new RouteDefinition(
method, ns(path), handler, scopeMw, mw,
!(handler instanceof SimpleHandler), ctx, namespace));
}
@Override
public FlashScope scan(String packageName) {
flushPending();
PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this;
}
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
@Override
public FlashContext ctx() { return ctx; }
// ── Internals (called by FlashApp.mount) ──────────────────────────────────
void flush() { flushPending(); }
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
List<RouteDefinition> routes() { return deferredRoutes; }
private String ns(String path) {
return namespace + PathUtils.sanitize(path);
}
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 InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
private String ns(String path) { return namespace + PathUtils.sanitize(path); }
}
@@ -2,7 +2,7 @@ package dev.relism.extension;
import dev.relism.exceptions.InitializationException;
import dev.relism.models.RequestHandler;
import dev.relism.routing.Route;
import dev.relism.routing.Routes;
import java.io.File;
import java.net.URL;
@@ -14,7 +14,8 @@ 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}.
* Finds {@link RequestHandler} subclasses in a package that carry a routing annotation
* ({@link Route @Route} or shorthand {@code @GET}, {@code @POST}, …).
* 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
@@ -26,8 +27,7 @@ final class PackageScanner {
private PackageScanner() {}
/**
* Returns all {@link RequestHandler} subclasses in {@code packageName} that carry
* {@link Route @Route}.
* Handlers in {@code packageName} that have {@link Routes#of(Class) resolvable} route metadata.
*
* @throws InitializationException if the package is empty, does not exist, or a
* handler class fails to load
@@ -76,8 +76,8 @@ final class PackageScanner {
if (result.isEmpty())
throw new InitializationException(
"scan(\"" + packageName + "\") — no @Route handlers found. " +
"Ensure handler classes extend RequestHandler, carry @Route, are not abstract, " +
"scan(\"" + packageName + "\") — no routable handlers found. " +
"Ensure classes extend RequestHandler, declare @Route or @GET/@POST/…, are not abstract, " +
"and have a public no-arg constructor.");
return result;
@@ -99,10 +99,11 @@ final class PackageScanner {
private static void scanJar(JarFile jar, String resourcePath, String packageName,
ClassLoader cl, List<Class<?>> result, List<String> errors) {
String prefix = resourcePath + "/";
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
String className = name.replace('/', '.').replace(".class", "");
tryLoad(className, cl, result, errors);
}
@@ -129,7 +130,7 @@ final class PackageScanner {
Class<?> cls = cl.loadClass(className);
if (!RequestHandler.class.isAssignableFrom(cls)) return;
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
if (!cls.isAnnotationPresent(Route.class)) return;
if (Routes.of(cls) == null) return;
// Verify no-arg constructor exists — fail-fast if missing
try {
@@ -4,6 +4,8 @@ import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.routing.Middleware;
import java.util.List;
/**
* Immutable snapshot of a route captured during the builder phase.
*
@@ -15,7 +17,7 @@ import dev.relism.routing.Middleware;
* @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 explicitMiddlewares middlewares passed inline at registration ({@code app.get(path, handler, mw...)})
* @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.)
@@ -24,8 +26,8 @@ record RouteDefinition(
HttpMethod method,
String path,
RequestHandler handler,
Middleware[] scopeMiddlewares,
Middleware[] explicitMiddlewares,
List<Middleware> scopeMiddlewares,
List<Middleware> explicitMiddlewares,
boolean classBasedHandler,
FlashContext ctx,
String namespace
@@ -18,14 +18,14 @@ import java.util.List;
* <ol>
* <li>Router-level middlewares (set on the router constructor)</li>
* <li>Annotation-injected middlewares (e.g. from {@code @Authenticated})</li>
* <li>Handler-level explicit middlewares (passed via {@code .with(...)})</li>
* <li>Handler-level explicit middlewares (passed inline: {@code app.get(path, h, mw...)})</li>
* </ol>
*
* <h3>Handler abstraction chain</h3>
* Walk {@link #handlerClass} upward via {@link Class#getSuperclass()} to reconstruct
* the full inheritance chain (e.g. {@code EditPostPageHandler → HtmlHandler → RequestHandler}).
* Read {@link Class#getAnnotations()} on each level to discover declared pointcuts
* ({@code @Authenticated}, {@code @RolesAllowed}, {@code @Route}, etc.).
* ({@code @Authenticated}, {@code @RolesAllowed}, {@code @Route}/{@code @GET}, etc.).
*
* @param method HTTP method for this route
* @param path full path as declared (including namespace prefix for scoped routes)
@@ -8,9 +8,27 @@ import java.util.ArrayList;
import java.util.List;
/**
* Lazy header access backed by a raw byte slice of the request buffer.
* No allocations at parse time; Strings are created only when {@link #first} / {@link #all} is called.
* One instance per connection; reused across keep-alive requests via {@link #reset}.
* Lazy, zero-copy header access backed directly by the request parser's byte buffer.
* Strings are allocated only when {@link #first} / {@link #all} / {@link #view} is called;
* the raw bytes are never copied at parse time.
*
* <h3>Lifetime contract — read carefully</h3>
* One {@code HeaderMap} instance lives on the connection (not per-request). On every
* keep-alive request {@link #reset} is called to slide the window over the new header
* section of the <em>same reused buffer</em>. This has two critical implications:
*
* <ol>
* <li><b>Do not retain the {@code HeaderMap} beyond the handler.</b> After the handler
* returns, the next request reuses and overwrites the buffer. Any {@code String}
* values retrieved via {@link #first}/{@link #all} are safe (they are independent
* heap copies); the {@code HeaderMap} object itself is not.</li>
* <li><b>{@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice
* into the live buffer.</b> Storing this view and reading it after the handler
* returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture}
* continuation, or a virtual-thread handoff) is a <em>data race</em> — the bytes
* may have been overwritten by the next request. Copy to a {@code String} or
* {@code byte[]} before leaving the synchronous handler scope.</li>
* </ol>
*/
@NoArgsConstructor
public class HeaderMap {
@@ -21,6 +21,15 @@ public class PathParams {
this.lens = lens;
}
/**
* Injects path parameters into a request. The cross-package bridge from
* {@link dev.relism.routing.AbstractRouter} — keeps {@link Request#setPathParams}
* package-private while allowing the router to set params without a public setter.
*/
public static void inject(Request request, PathParams params) {
request.setPathParams(params);
}
public String get(String name) {
int i = indexOf(name);
if (i < 0) return null;
@@ -4,7 +4,6 @@ import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.Value;
import lombok.experimental.NonFinal;
@@ -16,7 +15,7 @@ import java.util.List;
/**
* Immutable view of an incoming HTTP/1.1 request. Constructed by {@link dev.relism.RequestParser}
* and passed directly to route handlers never modified after creation (path/query params are
* and passed directly to route handlers; never modified after creation (path/query params are
* injected once by the router before the handler runs).
*
* <pre>{@code
@@ -41,17 +40,18 @@ public class Request {
/** Internal: the parsed request line (method, path, query, protocol, headers). */
RequestLine requestLine;
@NonFinal @Setter PathParams pathParams;
@NonFinal @Setter QueryParams queryParams;
@NonFinal PathParams pathParams;
@NonFinal QueryParams queryParams;
@NonFinal String cachedPath;
/**
* Remote socket address of the connected client. Set once at connection time from
* {@link java.net.Socket#getRemoteSocketAddress()} the {@link InetSocketAddress}
* {@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
* ({@code .getAddress().getHostAddress()}) is deferred to the caller, lazy and
* only paid when actually needed.
*/
@Getter(lombok.AccessLevel.NONE)
@@ -67,6 +67,13 @@ public class Request {
this.remoteAddress = remoteAddress;
}
/**
* Internal: injects path parameters after routing. Package-private so only
* {@link PathParams#inject} (same package) can call it — not user code.
* Use {@link PathParams#inject(Request, PathParams)} from routing code.
*/
void setPathParams(PathParams p) { this.pathParams = p; }
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */
public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body), null);
@@ -92,10 +99,11 @@ public class Request {
* Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}.
*/
public String path() {
if (cachedPath != null) return cachedPath;
ByteView v = requestLine.getPath();
byte[] buf = new byte[v.length()];
for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i);
return new String(buf, StandardCharsets.UTF_8);
return cachedPath = new String(buf, StandardCharsets.UTF_8);
}
// ── Headers ───────────────────────────────────────────────────────────────
@@ -41,19 +41,16 @@ public final class RequestBody {
this.preBufLen = preBufLen;
}
static RequestBody of(byte[] bytes) {
RequestBody b = new RequestBody(null, bytes.length, null, 0, 0);
b.resolved = bytes;
return b;
/** Pre-resolved body: test payloads and empty body — skips all I/O. */
private RequestBody(byte[] preResolved) {
this(null, preResolved.length, null, 0, 0);
this.resolved = preResolved;
}
private static final RequestBody EMPTY_INSTANCE;
static {
EMPTY_INSTANCE = new RequestBody(null, 0, null, 0, 0);
EMPTY_INSTANCE.resolved = EMPTY_BYTES;
}
private static final RequestBody EMPTY = new RequestBody(EMPTY_BYTES);
static RequestBody empty() { return EMPTY_INSTANCE; }
static RequestBody of(byte[] bytes) { return new RequestBody(bytes); }
static RequestBody empty() { return EMPTY; }
/** {@code true} if the body has zero bytes ({@code Content-Length: 0} or no body). */
public boolean isEmpty() { return contentLength == 0; }
@@ -7,9 +7,9 @@ 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.extension.FlashApp#register} or {@link dev.relism.extension.FlashApp#scan}.
* For one-off routes, prefer the lambda DSL ({@code app.get(path, handler)}).
* <p>Declare the route with {@link dev.relism.routing.Route @Route} or a shorthand such as
* {@code @GET("/path")}, then register via {@link dev.relism.extension.FlashApp#scan}.
* For ad-hoc routes use the fluent API ({@code app.get(path, handler)} or with middleware).
*
* <h3>Lifecycle</h3>
* <ol>
@@ -24,7 +24,7 @@ import java.util.Optional;
* into private fields. This keeps the hot-path ({@code handle}) free of map lookups.
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/users")
* @GET("/users")
* public class UserHandler extends RequestHandler {
* private UserService users;
*
@@ -47,8 +47,7 @@ public abstract class RequestHandler {
* 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.
* Use {@link dev.relism.extension.FlashApp#scan} instead.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
@@ -122,7 +121,7 @@ public abstract class RequestHandler {
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");
"register via FlashApp.scan(), not directly on the router");
}
/**
@@ -137,6 +137,16 @@ public class Response {
return this;
}
/**
* Adds a pre-encoded header (e.g. a static {@code "X-RateLimit-Limit: 100\r\n"} byte array
* pre-built at boot time). Zero-alloc on both the call path and the write path.
*/
public Response header(byte[] preEncoded) {
if (headers == null) headers = new ArrayList<>();
headers.add(preEncoded);
return this;
}
// -------------------------------------------------------------------------
// State queries
// -------------------------------------------------------------------------
@@ -147,21 +157,28 @@ public class Response {
// Internal setters used by HttpServer for handler return values
// -------------------------------------------------------------------------
/** Sets the body from an arbitrary handler return value. */
/**
* Sets the body from a handler return value. Accepted types: {@code byte[]},
* {@link String}, {@link CharSequence}. Any other non-null type throws
* {@link IllegalArgumentException} — return a {@code Response} directly, or
* serialize to {@code String}/{@code byte[]} before returning.
*/
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);
if (body instanceof byte[] bytes) { this.body = bytes; return this; }
if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
if (body != null) throw new IllegalArgumentException(
"Handler returned unsupported type: " + body.getClass().getName()
+ " — return String, byte[], Response, or null");
return this;
}
public void setContentType(ContentType ct) { this.contentType = ct.getBytes(); }
/** Returns custom headers, or an empty list if none were added. */
public List<byte[]> getHeaders() { return headers != null ? headers : List.of(); }
/** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */
public void writeHeaders(OutputStream out) throws IOException {
if (headers == null) return;
for (int i = 0, n = headers.size(); i < n; i++) out.write(headers.get(i));
for (byte[] header : headers) out.write(header);
}
}
@@ -1,16 +1,17 @@
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.
* Delegates {@link #handle} to a {@link FunctionalHandler} so lambdas can be used without
* relying on Lombok-generated constructors (IDE-safe, annotation-processing independent).
*/
@RequiredArgsConstructor
public class SimpleHandler extends RequestHandler {
private final FunctionalHandler delegate;
public SimpleHandler(FunctionalHandler delegate) {
this.delegate = delegate;
}
@Override
public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response);
@@ -1,11 +1,14 @@
package dev.relism.routing;
import dev.relism.Flash;
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 java.nio.charset.StandardCharsets;
/**
* Base router contract. Owns error handlers and the compile-time middleware
* wrapping logic. The only concrete implementation is
@@ -14,22 +17,48 @@ import dev.relism.template.ErrorPages;
* <p>All middleware composition happens once at boot time; the hot-path sees
* only a plain {@link RequestHandler} call with zero allocation overhead.
*
* <h3>Default error handlers</h3>
* <ul>
* <li><b>Dev mode</b> ({@code -Dflash.env=dev}): rich HTML pages with full stack traces.</li>
* <li><b>Prod mode</b> (default): generic JSON — no internal detail leaked to clients.</li>
* </ul>
* Override via {@link dev.relism.extension.FlashApp#onNotFound} /
* {@link dev.relism.extension.FlashApp#onException}.
*
* <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 {
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
res.setStatusCode(404);
res.setContentType(ContentType.TEXT_HTML);
return ErrorPages.renderNotFound(req);
});
// Pre-encoded prod JSON error bodies — zero allocation on error paths.
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
.getBytes(StandardCharsets.UTF_8);
private static final byte[] JSON_500 = "{\"error\":\"Internal Server Error\",\"status\":500}"
.getBytes(StandardCharsets.UTF_8);
protected ExceptionHandler exceptionHandler = (ex, req, res) -> {
res.setStatusCode(500);
res.setContentType(ContentType.TEXT_HTML);
return ErrorPages.renderException(req, ex);
};
protected SimpleHandler notFoundHandler = Flash.DEV
? new SimpleHandler((req, res) -> {
res.status(404);
res.type(ContentType.TEXT_HTML);
return ErrorPages.renderNotFound(req);
})
: new SimpleHandler((req, res) -> {
res.status(404);
res.type(ContentType.JSON);
return JSON_404;
});
protected ExceptionHandler exceptionHandler = Flash.DEV
? (ex, req, res) -> {
res.status(500);
res.type(ContentType.TEXT_HTML);
return ErrorPages.renderException(req, ex);
}
: (ex, req, res) -> {
res.status(500);
res.type(ContentType.JSON);
return JSON_500;
};
public SimpleHandler getNotFoundHandler() { return notFoundHandler; }
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
@@ -72,7 +101,7 @@ public abstract class AbstractRouter {
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
request.setPathParams(new PathParams(source, names, starts, lens));
PathParams.inject(request, new PathParams(source, names, starts, lens));
}
@FunctionalInterface
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.CONNECT, path = "…")}. */
@Route(method = HttpMethod.CONNECT, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CONNECT {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.DELETE, path = "…")}. */
@Route(method = HttpMethod.DELETE, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DELETE {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.GET, path = "…")}. */
@Route(method = HttpMethod.GET, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface GET {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.HEAD, path = "…")}. */
@Route(method = HttpMethod.HEAD, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface HEAD {
String value();
}
@@ -28,8 +28,8 @@ import dev.relism.models.SimpleHandler;
* AbstractRouter api = new MyRouter(cors, auth);
*
* // Handler-level: applied only to this route
* router.get("/admin", (req, res) -> "secret", auth);
* router.register(new AdminHandler(), auth, logging);
* app.get("/admin", (req, res) -> "secret", auth);
* app.get("/log", handler, logging, auth);
* }</pre>
*/
@FunctionalInterface
@@ -68,15 +68,4 @@ public interface Middleware {
};
}
/**
* Returns a composed middleware that applies {@code this} first, then {@code after}.
* Prefer {@link #of} for three or more middlewares.
*
* <pre>{@code
* Middleware secured = cors.andThen(auth);
* }</pre>
*/
default Middleware andThen(Middleware after) {
return next -> this.wrap(new SimpleHandler(after.wrap(next)));
}
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.OPTIONS, path = "…")}. */
@Route(method = HttpMethod.OPTIONS, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface OPTIONS {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.PATCH, path = "…")}. */
@Route(method = HttpMethod.PATCH, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PATCH {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.POST, path = "…")}. */
@Route(method = HttpMethod.POST, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface POST {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.PURGE, path = "…")}. */
@Route(method = HttpMethod.PURGE, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PURGE {
String value();
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.PUT, path = "…")}. */
@Route(method = HttpMethod.PUT, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PUT {
String value();
}
@@ -8,16 +8,16 @@ 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}.
* HTTP method and path for a class-based {@link dev.relism.models.RequestHandler}.
* Path is relative to the mount namespace (e.g. {@code path = "/profile"} under {@code /api}
* → {@code /api/profile}).
*
* <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}.
* <p>Prefer shorthand ({@code @GET("/x")}, {@code @POST("/x")}, …); they are meta-annotated
* with {@code @Route} and resolve to the same metadata as an explicit {@code @Route(method=…, path=…)}
* via {@link Routes#of(Class)}.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
public @interface Route {
HttpMethod method() default HttpMethod.GET;
String path();
@@ -1,70 +0,0 @@
package dev.relism.routing;
import java.util.function.Consumer;
/**
* Deferred route registration handle returned by {@code router.get()},
* {@code router.post()}, {@code app.get()}, {@code app.register()}, etc.
*
* <p>The route is <em>not</em> registered until {@link #with} is called.
* This separates the handler declaration from the middleware declaration,
* keeping the registration methods clean.
*
* <pre>{@code
* // No middleware
* app.get("/ping", (req, res) -> "pong").with();
*
* // With middleware
* app.get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect());
* app.get("/admin", (req, res) -> "admin").with(oidc.requireRole("admin"));
*
* // Class-based handler — annotations (@Authenticated, @RolesAllowed) are
* // processed automatically; .with() can add extra middleware on top.
* app.register(new MyHandler()).with();
* app.register(new SecuredHandler()).with(logging);
* }</pre>
*
* @param <P> the parent type returned by {@link #with} for further chaining
* ({@link dev.relism.extension.FlashApp} or {@link AbstractRouter})
*/
public final class RouteHandle<P> {
private final P parent;
private final Consumer<Middleware[]> registerFn;
private boolean registered = false;
public RouteHandle(P parent, Consumer<Middleware[]> registerFn) {
this.parent = parent;
this.registerFn = registerFn;
}
/**
* Registers the route with the given middlewares and returns the parent
* for further chaining.
*
* <p>Calling without arguments is equivalent to registering with no middleware.
* When using {@link dev.relism.extension.FlashApp}, calling {@code .with()} is
* optional for routes that need no middleware — the route is registered
* automatically before the next operation or at {@code start()}.
*
* @param middlewares zero or more middlewares; first element executes outermost
* @return the parent ({@link dev.relism.extension.FlashApp} or
* {@link AbstractRouter}) for further method chaining
*/
public P with(Middleware... middlewares) {
if (!registered) {
registerFn.accept(middlewares);
registered = true;
}
return parent;
}
/**
* Registers the route with no middleware if it has not been registered yet.
* Called automatically by {@link dev.relism.extension.FlashApp} before each
* new route registration and at {@code start()}.
*/
public void ensureRegistered() {
with();
}
}
@@ -0,0 +1,34 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.Annotation;
/** Boot-time resolution of {@link Route} from a handler class (direct {@code @Route} or shorthand). */
public final class Routes {
private Routes() {}
/**
* Effective route for {@code cls}, or {@code null}. Prefers {@code @Route} on the class;
* otherwise the first annotation type meta-annotated with {@code @Route} (e.g. {@code @GET}).
* If several routing annotations are present, behaviour is undefined — use one.
*/
public static Route of(Class<?> cls) {
Route direct = cls.getAnnotation(Route.class);
if (direct != null) return direct;
for (Annotation ann : cls.getAnnotations()) {
Route meta = ann.annotationType().getAnnotation(Route.class);
if (meta == null) continue;
try {
String path = (String) ann.annotationType().getMethod("value").invoke(ann);
final HttpMethod method = meta.method();
return new Route() {
public HttpMethod method() { return method; }
public String path() { return path; }
public Class<? extends Annotation> annotationType() { return Route.class; }
};
} catch (ReflectiveOperationException ignored) {}
}
return null;
}
}
@@ -0,0 +1,16 @@
package dev.relism.routing;
import dev.relism.http.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Shorthand for {@code @Route(method = HttpMethod.TRACE, path = "…")}. */
@Route(method = HttpMethod.TRACE, path = "")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TRACE {
String value();
}