weeks of bullshit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class HttpServerConcurrencyTest {
|
||||
return new Response(200, body, ContentType.TEXT_PLAIN);
|
||||
});
|
||||
|
||||
app.start().get(5, TimeUnit.SECONDS);
|
||||
app.start();
|
||||
|
||||
httpClient = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
@@ -188,7 +188,7 @@ class HttpServerConcurrencyTest {
|
||||
freshApp.get("/route" + idx, (req, res) -> "handler" + idx);
|
||||
}
|
||||
|
||||
freshApp.start().get(5, TimeUnit.SECONDS);
|
||||
freshApp.start();
|
||||
|
||||
int count = 20;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(count);
|
||||
|
||||
@@ -61,7 +61,7 @@ class HttpServerTest {
|
||||
return res.body(body);
|
||||
});
|
||||
|
||||
app.start().get(5, TimeUnit.SECONDS);
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -148,29 +148,31 @@ class HttpServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotFound_returns404Html() throws Exception {
|
||||
void testNotFound_returns404() throws Exception {
|
||||
String req = "GET /api/unknown HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||
assertTrue(res.contains("404"));
|
||||
assertTrue(res.contains("No route matched this request"));
|
||||
// Prod default: JSON body — no internal details leaked
|
||||
assertTrue(res.contains("\"status\":404"));
|
||||
assertTrue(res.contains("application/json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testException_returns500Html() throws Exception {
|
||||
void testException_returns500() throws Exception {
|
||||
String req = "GET /api/crash HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error"));
|
||||
assertTrue(res.contains("500"));
|
||||
assertTrue(res.contains("Simulated Crash"));
|
||||
// Prod default: generic JSON — stack trace not leaked
|
||||
assertTrue(res.contains("\"status\":500"));
|
||||
assertTrue(res.contains("application/json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRoot_returns404_noExceptionTossed() throws Exception {
|
||||
void testRoot_returns404() throws Exception {
|
||||
String req = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||
assertTrue(res.contains("No route matched this request."));
|
||||
assertTrue(res.contains("\"status\":404"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -58,10 +58,23 @@ class ResponseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withObjectConvertedToString() {
|
||||
void setBody_withString() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setBody(12345);
|
||||
assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
r.setBody("hello");
|
||||
assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withCharSequence() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setBody(new StringBuilder("hi"));
|
||||
assertArrayEquals("hi".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withUnsupportedType_throws() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
assertThrows(IllegalArgumentException.class, () -> r.setBody(12345));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
@@ -52,7 +51,7 @@ class AbstractRouterTest {
|
||||
void doRegister_classBasedHandler_addsRoute() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
ProfileHandler handler = new ProfileHandler();
|
||||
Route ann = ProfileHandler.class.getAnnotation(Route.class);
|
||||
Route ann = Routes.of(ProfileHandler.class);
|
||||
|
||||
router.doRegister(ann.method(), ann.path(), handler, new Middleware[0]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user