preparing for a conceptual refactoring...
This commit is contained in:
@@ -4,9 +4,8 @@ import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import dev.relism.models.*;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.extension.FlashConfiguration;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -22,29 +21,32 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Flash HTTP server. Owns a {@link GlobalRouter} with two routing tiers:
|
||||
* mounted sub-routers (matched by longest namespace prefix) and an internal
|
||||
* router as fallback. Error handlers are scoped to their respective router.
|
||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread executor,
|
||||
* and the keep-alive accept loop. All routing is delegated to the {@link GlobalRouter}
|
||||
* supplied at construction time.
|
||||
*
|
||||
* <p>This class is package-private — use {@link dev.relism.extension.FlashApp} as the
|
||||
* single entry point for creating and configuring a Flash server.
|
||||
*/
|
||||
@Slf4j
|
||||
public class HttpServer {
|
||||
private final HttpServerConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final GlobalRouter globalRouter;
|
||||
class HttpServer implements ServerHandle {
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final GlobalRouter globalRouter;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final byte[][] DIGITS = new byte[10][1];
|
||||
@@ -54,16 +56,20 @@ public class HttpServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the server with optional server-level middlewares applied to every registered route.
|
||||
* Middlewares are pre-fused at construction time; the first element executes outermost.
|
||||
* Creates the transport with a pre-built router. Called exclusively by
|
||||
* {@link dev.relism.extension.FlashApp}.
|
||||
*
|
||||
* @param configuration server configuration (port, host, buffer sizes)
|
||||
* @param globalRouter the fully-wired router to dispatch requests to
|
||||
*/
|
||||
public HttpServer(HttpServerConfiguration configuration, Middleware... middlewares) throws IOException {
|
||||
HttpServer(FlashConfiguration configuration, GlobalRouter globalRouter) throws IOException {
|
||||
this.configuration = configuration;
|
||||
this.serverSocket = new ServerSocket(configuration.getPort());
|
||||
this.globalRouter = new GlobalRouter(middlewares);
|
||||
this.globalRouter = globalRouter;
|
||||
}
|
||||
|
||||
/** Returns a future that completes once the accept loop is running and the server is ready. */
|
||||
@Override
|
||||
public CompletableFuture<Void> start() {
|
||||
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
|
||||
return readyFuture;
|
||||
@@ -85,6 +91,7 @@ public class HttpServer {
|
||||
}
|
||||
|
||||
/** Closes all active connections and shuts down the executor. Returns when complete. */
|
||||
@Override
|
||||
public CompletableFuture<Void> stop() {
|
||||
stopped = true;
|
||||
try {
|
||||
@@ -104,59 +111,7 @@ public class HttpServer {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a sub-router under {@code namespace}. Requests whose path starts with the
|
||||
* namespace are dispatched to {@code router}; longest prefix wins.
|
||||
*
|
||||
* @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted
|
||||
*/
|
||||
public HttpServer mount(String namespace, AbstractRouter router) {
|
||||
globalRouter.mount(namespace, router);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
globalRouter.onNotFound(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer onException(AbstractRouter.ExceptionHandler handler) {
|
||||
globalRouter.onException(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Direct registration (called by FlashApp via RouteHandle.with()) ──────
|
||||
|
||||
public HttpServer doRegister(dev.relism.http.HttpMethod method, String path,
|
||||
SimpleHandler.FunctionalHandler handler,
|
||||
dev.relism.routing.Middleware[] middlewares) {
|
||||
globalRouter.doRegister(method, path, handler, middlewares);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer doRegister(RequestHandler handler,
|
||||
dev.relism.routing.Middleware[] middlewares) {
|
||||
globalRouter.doRegister(handler, middlewares);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Fluent registration (delegates to globalRouter) ───────────────────────
|
||||
|
||||
public dev.relism.routing.RouteHandle<HttpServer> register(RequestHandler handler) {
|
||||
return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(handler, m));
|
||||
}
|
||||
|
||||
public dev.relism.routing.RouteHandle<HttpServer> get (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.GET, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> post (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.POST, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> put (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PUT, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> delete (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.DELETE, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> patch (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PATCH, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> options(String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.OPTIONS, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> head (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.HEAD, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> trace (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.TRACE, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> connect(String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.CONNECT, path, h, m)); }
|
||||
public dev.relism.routing.RouteHandle<HttpServer> purge (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PURGE, path, h, m)); }
|
||||
|
||||
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void process(Socket socket) {
|
||||
activeSockets.add(socket);
|
||||
@@ -210,7 +165,6 @@ public class HttpServer {
|
||||
|
||||
private static boolean isKeepAlive(Request request) {
|
||||
if (request.headerEquals("Connection", "close")) return false;
|
||||
// Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1', "HTTP/1.0" → '0'
|
||||
ByteView protocol = request.getRequestLine().getProtocol();
|
||||
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|
||||
|| request.headerEquals("Connection", "keep-alive");
|
||||
@@ -246,7 +200,6 @@ public class HttpServer {
|
||||
out.flush();
|
||||
}
|
||||
|
||||
/** Streaming write path — extracted from {@link #writeResponse} to keep the hot method small. */
|
||||
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
if (!response.isChunked()) {
|
||||
out.write(CONTENT_LENGTH);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package dev.relism;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class HttpServerConfiguration {
|
||||
private int port;
|
||||
private String host;
|
||||
@Builder.Default
|
||||
private int acceptorThreads = 1;
|
||||
@Builder.Default
|
||||
private int maxHeaderBufferSize = 64 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.extension.FlashConfiguration;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Public handle to the underlying HTTP transport. Returned by {@link #create} so that
|
||||
* {@link dev.relism.extension.FlashApp} can start and stop the server without holding
|
||||
* a direct reference to the package-private {@link HttpServer}.
|
||||
*/
|
||||
public interface ServerHandle {
|
||||
|
||||
CompletableFuture<Void> start();
|
||||
CompletableFuture<Void> stop();
|
||||
|
||||
/**
|
||||
* Creates the HTTP transport. Called exclusively by
|
||||
* {@link dev.relism.extension.FlashApp}.
|
||||
*/
|
||||
static ServerHandle create(FlashConfiguration config, GlobalRouter router) throws IOException {
|
||||
return new HttpServer(config, router);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Shared registry passed to every extension during {@link FlashExtension#install}.
|
||||
@@ -9,14 +10,36 @@ import java.util.*;
|
||||
* <li><b>Service sharing</b> — provide/require typed objects (e.g. {@code ObjectMapper},
|
||||
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
|
||||
* <li><b>Annotation processing</b> — register {@link AnnotationProcessor}s that
|
||||
* {@link FlashApp} calls for every handler, injecting middleware derived from
|
||||
* are invoked for every handler, injecting middleware derived from
|
||||
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>A child context (created via {@link #child()}) inherits all services and processors
|
||||
* from its parent. Services provided and processors added on the child are scoped to it
|
||||
* and not visible in the parent or sibling scopes.
|
||||
*/
|
||||
public class ExtensionContext {
|
||||
|
||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final ExtensionContext parent;
|
||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final List<RouteListener> routeListeners = new ArrayList<>();
|
||||
|
||||
public ExtensionContext() {
|
||||
this.parent = null;
|
||||
}
|
||||
|
||||
private ExtensionContext(ExtensionContext parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a child context that inherits this context's services and processors.
|
||||
* Services provided and processors added on the child do not affect the parent.
|
||||
*/
|
||||
public ExtensionContext child() {
|
||||
return new ExtensionContext(this);
|
||||
}
|
||||
|
||||
// ── Service registry ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,11 +50,13 @@ public class ExtensionContext {
|
||||
|
||||
/**
|
||||
* Retrieves the service registered under {@code type}.
|
||||
* Throws {@link IllegalStateException} if not present — install order matters.
|
||||
* Checks own scope first, then the parent chain.
|
||||
* Throws {@link IllegalStateException} if not found — install order matters.
|
||||
*/
|
||||
@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(
|
||||
"Extension dependency not found: " + type.getSimpleName() +
|
||||
@@ -39,24 +64,58 @@ public class ExtensionContext {
|
||||
return val;
|
||||
}
|
||||
|
||||
/** Returns the service under {@code type}, or empty if not installed. */
|
||||
/** Returns the service under {@code type}, or empty if not installed in this scope or any parent. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> Optional<T> find(Class<T> type) {
|
||||
return Optional.ofNullable((T) registry.get(type));
|
||||
T val = (T) registry.get(type);
|
||||
if (val != null) return Optional.of(val);
|
||||
return parent != null ? parent.find(type) : Optional.empty();
|
||||
}
|
||||
|
||||
// ── Annotation processors ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers an {@link AnnotationProcessor}. Called by extensions during
|
||||
* {@link FlashExtension#install}. Processors are invoked in registration order.
|
||||
* {@link FlashExtension#install}. Processors are invoked in registration order
|
||||
* (parent processors first, then own).
|
||||
*/
|
||||
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
||||
processors.add(processor);
|
||||
}
|
||||
|
||||
/** Returns an unmodifiable view of all registered processors. */
|
||||
/**
|
||||
* Returns all processors visible from this context: parent processors first,
|
||||
* then processors added directly to this context.
|
||||
*/
|
||||
List<AnnotationProcessor> processors() {
|
||||
return Collections.unmodifiableList(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();
|
||||
}
|
||||
|
||||
// ── Route listeners ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers a {@link RouteListener} that will be notified once for every route
|
||||
* registered on this context's {@link dev.relism.extension.FlashApp} or any
|
||||
* {@link dev.relism.extension.FlashScope} that inherits from it.
|
||||
*
|
||||
* <p>Call this inside {@link FlashExtension#install} to observe all routes.
|
||||
* If no listener is registered the emission path is a no-op.
|
||||
*/
|
||||
public void addRouteListener(RouteListener listener) {
|
||||
routeListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all route listeners visible from this context: parent listeners first,
|
||||
* then listeners added directly to this context.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,107 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.HttpServer;
|
||||
import dev.relism.ServerHandle;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
import dev.relism.routing.Middleware;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Primary entry point for working with Flash. Wraps {@link HttpServer} and
|
||||
* wires the extension layer.
|
||||
*
|
||||
* <p>The key addition over raw {@link HttpServer} is the annotation-aware
|
||||
* {@link #register} override: before delegating to Flash it runs all registered
|
||||
* {@link AnnotationProcessor}s and prepends any injected middlewares
|
||||
* (e.g. from {@code @RolesAllowed}) to the explicit ones.
|
||||
*
|
||||
* <p>Route registration is lazy: calling {@code get()}, {@code post()}, or
|
||||
* {@code register()} returns a {@link RouteHandle} that is not yet registered.
|
||||
* The route is registered automatically before the next operation or at
|
||||
* {@link #start()}. Call {@link RouteHandle#with} explicitly to add middleware:
|
||||
* Primary entry point for Flash. Creates and owns both the {@link GlobalRouter} and
|
||||
* the {@link HttpServer} (pure I/O transport). All route registration goes through
|
||||
* {@code FlashApp} or a {@link FlashScope} — never through the server or router directly.
|
||||
*
|
||||
* <p>Create via the static factories:
|
||||
* <pre>{@code
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OidcExtension(OidcConfig.fromEnv()))
|
||||
* .install(new OpenApiExtension("/openapi"))
|
||||
* .start();
|
||||
*
|
||||
* // No middleware — .with() is optional
|
||||
* app.get("/ping", (req, res) -> "pong");
|
||||
* app.get("/hello", (req, res) -> "Hello");
|
||||
*
|
||||
* // With middleware
|
||||
* app.get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect());
|
||||
* app.get("/admin", (req, res) -> "secret").with(oidc.requireRole("admin"));
|
||||
*
|
||||
* // Class-based — @Authenticated / @RolesAllowed auto-injected, .with() optional
|
||||
* app.register(new HomePage());
|
||||
* app.register(new MePage()); // @Authenticated handled by annotation processor
|
||||
* app.register(new AdminPage()); // @RolesAllowed("admin") handled automatically
|
||||
*
|
||||
* app.start();
|
||||
* FlashApp app = FlashApp.create(8080);
|
||||
* FlashApp app = FlashApp.create(FlashConfiguration.builder().port(8080).build());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Install extensions, register routes, mount namespaces, then start:
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OidcExtension(config))
|
||||
* .get("/ping", (req, res) -> "pong")
|
||||
* .get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect())
|
||||
* .register(new HomePage()) // @Route + annotation processors applied
|
||||
* .scan("dev.example.handlers") // classpath scan, no-arg constructors
|
||||
* .mount("/api", scope -> {
|
||||
* scope.register(new UserHandler()); // @Authenticated works here
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* })
|
||||
* .start();
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Auto-flush</h3>
|
||||
* Calling any registration method returns a {@link RouteHandle}. Calling
|
||||
* {@link RouteHandle#with} is optional — if omitted, the route is registered
|
||||
* automatically before the next operation or at {@link #start()}. This means
|
||||
* trailing {@code .with()} calls are never required for routes with no middleware.
|
||||
*/
|
||||
public class FlashApp {
|
||||
public final class FlashApp implements FlashRegistrar {
|
||||
|
||||
private final HttpServer server;
|
||||
private final GlobalRouter router;
|
||||
private final ServerHandle server;
|
||||
private final ExtensionContext ctx = new ExtensionContext();
|
||||
|
||||
/** The last returned RouteHandle that has not yet been registered. */
|
||||
/** The last returned RouteHandle not yet registered — auto-flushed before the next operation. */
|
||||
private RouteHandle<?> pending;
|
||||
|
||||
private FlashApp(HttpServer server) {
|
||||
this.server = server;
|
||||
/**
|
||||
* Global middlewares applied to every route, regardless of how it is registered
|
||||
* (lambda, class-based, or via {@link #scan}).
|
||||
* Accumulated via {@link #use}; applied outermost in the chain (before injected and
|
||||
* explicit middlewares).
|
||||
*/
|
||||
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||
|
||||
private FlashApp(FlashConfiguration config) {
|
||||
this.router = new GlobalRouter();
|
||||
try {
|
||||
this.server = ServerHandle.create(config, router);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to bind server socket on port " + config.getPort(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static FlashApp of(HttpServer server) {
|
||||
return new FlashApp(server);
|
||||
// ── Factories ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a {@code FlashApp} listening on {@code port} with default configuration.
|
||||
*
|
||||
* @param port the TCP port to bind
|
||||
*/
|
||||
public static FlashApp create(int port) {
|
||||
return create(FlashConfiguration.builder().port(port).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code FlashApp} with full server configuration.
|
||||
*
|
||||
* @param config server configuration (port, host, buffer sizes, etc.)
|
||||
*/
|
||||
public static FlashApp create(FlashConfiguration config) {
|
||||
return new FlashApp(config);
|
||||
}
|
||||
|
||||
// ── Pending flush ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers any pending route (returned by the previous {@code get/post/register}
|
||||
* call) with no middleware, if it has not already been registered via
|
||||
* {@link RouteHandle#with}.
|
||||
* Registers any pending route (from the previous {@code get/post/register} call)
|
||||
* with no middleware if it has not already been committed via {@link RouteHandle#with}.
|
||||
*/
|
||||
private void flushPending() {
|
||||
if (pending != null) {
|
||||
@@ -80,103 +110,239 @@ public class FlashApp {
|
||||
}
|
||||
}
|
||||
|
||||
private <P> RouteHandle<P> pending(RouteHandle<P> handle) {
|
||||
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
|
||||
flushPending();
|
||||
pending = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
// ── Extension installation ────────────────────────────────────────────────
|
||||
// ── FlashRegistrar — extension installation ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Installs an extension. Extensions receive this {@code FlashApp} as a
|
||||
* {@link FlashRegistrar} so they can register routes and expose services.
|
||||
*
|
||||
* @param ext the extension to install
|
||||
* @return {@code this} for chaining
|
||||
*/
|
||||
@Override
|
||||
public FlashApp install(FlashExtension ext) {
|
||||
flushPending();
|
||||
ext.install(this, ctx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Exposes the context so callers can retrieve services installed by extensions. */
|
||||
// ── Global middleware ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers one or more global middlewares applied to <em>every</em> route on this app,
|
||||
* regardless of how the route is registered (lambda, class-based, or via {@link #scan}).
|
||||
*
|
||||
* <p>Global middlewares execute outermost — before annotation-injected middlewares
|
||||
* (e.g. {@code @Authenticated}) and before any explicit {@link RouteHandle#with} chain.
|
||||
* Execution order mirrors the declaration order: the first argument wraps everything else.
|
||||
*
|
||||
* <p>Must be called before {@link #start()}. Calling {@code use} after routes have already
|
||||
* been registered will not retroactively affect those routes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* Middleware cors = next -> (req, res) -> {
|
||||
* res.header("Access-Control-Allow-Origin", "*");
|
||||
* if (req.method() == HttpMethod.OPTIONS) { res.status(204); return null; }
|
||||
* return next.handle(req, res);
|
||||
* };
|
||||
*
|
||||
* FlashApp.create(8080)
|
||||
* .use(cors)
|
||||
* .scan("dev.example.handlers")
|
||||
* .start();
|
||||
* }</pre>
|
||||
*
|
||||
* @param middlewares one or more middlewares to apply globally
|
||||
* @return {@code this} for chaining
|
||||
*/
|
||||
public FlashApp use(Middleware... middlewares) {
|
||||
flushPending();
|
||||
globalMiddlewares.addAll(Arrays.asList(middlewares));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends global middlewares to an explicit per-route array.
|
||||
* Returns {@code explicit} unchanged when no global middlewares have been registered
|
||||
* (zero-allocation fast path).
|
||||
*/
|
||||
private Middleware[] withGlobal(Middleware[] explicit) {
|
||||
if (globalMiddlewares.isEmpty()) return explicit;
|
||||
return Stream.concat(globalMiddlewares.stream(), Arrays.stream(explicit))
|
||||
.toArray(Middleware[]::new);
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — route registration ───────────────────────────────────
|
||||
|
||||
@Override public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
|
||||
@Override public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
|
||||
@Override public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
|
||||
@Override public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
|
||||
@Override public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
|
||||
@Override public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
|
||||
@Override public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
|
||||
@Override public RouteHandle<FlashApp> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
|
||||
@Override public RouteHandle<FlashApp> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
|
||||
@Override public RouteHandle<FlashApp> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
|
||||
|
||||
private RouteHandle<FlashApp> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
return track(new RouteHandle<>(this, m -> {
|
||||
Middleware[] all = withGlobal(m);
|
||||
emit(method, path, null, List.of(), all);
|
||||
router.doRegister(method, path, h, all);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. The class must carry a
|
||||
* {@link Route @Route} annotation. All registered {@link AnnotationProcessor}s
|
||||
* are run (e.g. to inject {@code @Authenticated} / {@code @RolesAllowed} middleware).
|
||||
* Injected middlewares are prepended outermost to any explicit ones passed via
|
||||
* {@link RouteHandle#with}.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or at {@link #start()}.
|
||||
*/
|
||||
@Override
|
||||
public RouteHandle<FlashApp> register(RequestHandler handler) {
|
||||
return track(new RouteHandle<>(this, explicit -> {
|
||||
List<Middleware> injected = ctx.processors().stream()
|
||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||
.toList();
|
||||
Middleware[] all = Stream.concat(
|
||||
globalMiddlewares.stream(),
|
||||
Stream.concat(injected.stream(), Arrays.stream(explicit))
|
||||
).toArray(Middleware[]::new);
|
||||
Route ann = handler.getClass().getAnnotation(Route.class);
|
||||
if (ann != null) emit(ann.method(), ann.path(), handler.getClass(), injected, withGlobal(explicit));
|
||||
router.doRegister(handler, all);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||
* carry {@link Route @Route}. Each is instantiated via its no-arg constructor,
|
||||
* run through annotation processors, and registered.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new OidcExtension(config))
|
||||
* .scan("dev.example.handlers"); // @Authenticated / @RolesAllowed auto-applied
|
||||
* }</pre>
|
||||
*/
|
||||
@Override
|
||||
public FlashApp scan(String packageName) {
|
||||
flushPending();
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Namespace mounting ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mounts a scoped sub-router under {@code namespace}. The {@code configure} consumer
|
||||
* receives a {@link FlashScope} that has its own child {@link ExtensionContext}
|
||||
* inheriting all parent services and annotation processors.
|
||||
*
|
||||
* <p>Routes registered on the scope automatically get the namespace prefix prepended.
|
||||
* Annotation processors (e.g. from OIDC) apply identically inside the scope.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.mount("/api", scope -> {
|
||||
* scope.register(new UserHandler()); // @Authenticated works
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* scope.scan("dev.example.api");
|
||||
* });
|
||||
* }</pre>
|
||||
*
|
||||
* @param namespace the path prefix (e.g. {@code "/api"})
|
||||
* @param configure consumer that registers routes on the scope
|
||||
*/
|
||||
public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
|
||||
flushPending();
|
||||
FlashScope scope = new FlashScope(new dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl(),
|
||||
namespace, ctx);
|
||||
configure.accept(scope);
|
||||
scope.flush();
|
||||
router.mount(namespace, scope.router());
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — error handlers ──────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
|
||||
flushPending();
|
||||
router.onException(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
flushPending();
|
||||
router.onNotFound(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — context ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public ExtensionContext ctx() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Handler registration (annotation-aware) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. Before registering, all
|
||||
* {@link AnnotationProcessor}s are called (e.g. to inject OIDC middleware from
|
||||
* {@code @Authenticated} / {@code @RolesAllowed}). Their middlewares are prepended
|
||||
* outermost to any extra middlewares supplied via {@link RouteHandle#with}.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional when no extra middleware is needed —
|
||||
* the route is registered automatically before the next operation or at {@link #start()}.
|
||||
*
|
||||
* <p>Middleware execution order: injected (from annotations) → explicit (.with()) → handler.
|
||||
*/
|
||||
public RouteHandle<FlashApp> register(RequestHandler handler) {
|
||||
return pending(new RouteHandle<>(this, explicit -> {
|
||||
List<Middleware> injected = ctx.processors().stream()
|
||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||
.toList();
|
||||
Middleware[] all = injected.isEmpty()
|
||||
? explicit
|
||||
: Stream.concat(injected.stream(), Arrays.stream(explicit))
|
||||
.toArray(Middleware[]::new);
|
||||
server.doRegister(handler, all);
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Lambda route registration ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Begins registration of a lambda route. Calling {@link RouteHandle#with} is
|
||||
* optional when no middleware is needed — the route is registered automatically
|
||||
* before the next operation or at {@link #start()}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/ping", (req, res) -> "pong"); // no middleware
|
||||
* app.get("/me", (req, res) -> ClaimsHolder.user().email())
|
||||
* .with(oidc.protect()); // with middleware
|
||||
* }</pre>
|
||||
*/
|
||||
public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.GET, path, h, m))); }
|
||||
public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.POST, path, h, m))); }
|
||||
public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.PUT, path, h, m))); }
|
||||
public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.DELETE, path, h, m))); }
|
||||
public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.PATCH, path, h, m))); }
|
||||
public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.OPTIONS, path, h, m))); }
|
||||
public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.HEAD, path, h, m))); }
|
||||
|
||||
public FlashApp mount(String namespace, AbstractRouter router) {
|
||||
flushPending();
|
||||
server.mount(namespace, router);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
|
||||
flushPending();
|
||||
server.onException(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
flushPending();
|
||||
server.onNotFound(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flushes any pending route registration and starts the HTTP server.
|
||||
*
|
||||
* @return a future that completes once the accept loop is running
|
||||
*/
|
||||
public CompletableFuture<Void> start() {
|
||||
flushPending();
|
||||
return server.start();
|
||||
}
|
||||
|
||||
/** Stops the HTTP server and closes all active connections. */
|
||||
public CompletableFuture<Void> stop() {
|
||||
return server.stop();
|
||||
}
|
||||
|
||||
/** Direct access to the underlying server for advanced use cases. */
|
||||
public HttpServer server() {
|
||||
return server;
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static RequestHandler instantiate(Class<?> cls) {
|
||||
try {
|
||||
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
|
||||
" — ensure it has a public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a {@link RouteEvent} to all registered {@link RouteListener}s.
|
||||
* No-op if no listener has been registered (fast empty-list check).
|
||||
* Called once per route at boot time — never on the request hot-path.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void emit(HttpMethod method, String path, Class<?> handlerClass,
|
||||
List<Middleware> injected, Middleware[] explicit) {
|
||||
List<RouteListener> listeners = ctx.routeListeners();
|
||||
if (listeners.isEmpty()) return;
|
||||
Middleware[] routerMws = router.routerMiddlewares();
|
||||
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
|
||||
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
RouteEvent event = new RouteEvent(method, path, router.getNamespace(),
|
||||
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* Configuration for a {@link FlashApp} instance.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Minimal — port only
|
||||
* FlashApp.create(8080);
|
||||
*
|
||||
* // Full control
|
||||
* FlashApp.create(FlashConfiguration.builder()
|
||||
* .port(8080)
|
||||
* .host("127.0.0.1")
|
||||
* .maxHeaderBufferSize(128 * 1024)
|
||||
* .build());
|
||||
* }</pre>
|
||||
*/
|
||||
@Value
|
||||
@Builder
|
||||
public class FlashConfiguration {
|
||||
|
||||
int port;
|
||||
String host;
|
||||
|
||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||
@Builder.Default
|
||||
int maxHeaderBufferSize = 64 * 1024;
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
/**
|
||||
* Contract for all Flash extensions. An extension receives the full {@link FlashApp}
|
||||
* so it can both register handlers on the server and expose services through
|
||||
* {@link ExtensionContext} for other extensions.
|
||||
* Contract for all Flash extensions. An extension receives a {@link FlashRegistrar}
|
||||
* (either a {@link FlashApp} or a {@link FlashScope}) so it can register routes and
|
||||
* expose shared services via {@link ExtensionContext}.
|
||||
*
|
||||
* <p>Extensions work identically whether installed at the top-level app or inside a
|
||||
* mounted scope:
|
||||
*
|
||||
* <pre>{@code
|
||||
* public class OpenApiExtension implements FlashExtension {
|
||||
* public void install(FlashApp app, ExtensionContext ctx) {
|
||||
* ObjectMapper mapper = ctx.require(ObjectMapper.class);
|
||||
* app.get("/openapi.json", (req, res) -> mapper.writeValueAsString(spec));
|
||||
* public class RateLimitExtension implements FlashExtension {
|
||||
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
* RateLimiter limiter = new RateLimiter(100);
|
||||
* ctx.provide(RateLimiter.class, limiter);
|
||||
* app.onException((ex, req, res) -> { ... });
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* // Top-level app
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OpenApiExtension("/openapi.json"))
|
||||
* .install(new OidcExtension(OidcConfig.fromEnv()));
|
||||
*
|
||||
* // Scoped
|
||||
* app.mount("/api", scope -> scope.install(new RateLimitExtension()));
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FlashExtension {
|
||||
void install(FlashApp app, ExtensionContext ctx);
|
||||
void install(FlashRegistrar app, ExtensionContext ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
|
||||
/**
|
||||
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
|
||||
*
|
||||
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so that extensions
|
||||
* work identically whether installed at the top-level app or inside a mounted scope.
|
||||
*
|
||||
* <p>Route registration follows the auto-flush pattern: calling any registration method
|
||||
* without a subsequent {@link RouteHandle#with} is equivalent to calling
|
||||
* {@code .with()} with no arguments — the route is registered with no middleware.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // In an extension:
|
||||
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
* app.get("/health", (req, res) -> "ok");
|
||||
* app.get("/secured", (req, res) -> user()).with(oidc.protect());
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public interface FlashRegistrar {
|
||||
|
||||
// ── Extension installation ────────────────────────────────────────────────
|
||||
|
||||
FlashRegistrar install(FlashExtension ext);
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. The class must carry a
|
||||
* {@link dev.relism.routing.Route @Route} annotation. Annotation processors
|
||||
* (e.g. {@code @Authenticated}, {@code @RolesAllowed}) are applied automatically.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or at {@code start()}.
|
||||
*/
|
||||
RouteHandle<?> register(RequestHandler h);
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||
* carry {@link dev.relism.routing.Route @Route}. Each is instantiated via its
|
||||
* no-arg constructor, run through annotation processors, and registered.
|
||||
*/
|
||||
FlashRegistrar scan(String packageName);
|
||||
|
||||
// ── Error handlers ────────────────────────────────────────────────────────
|
||||
|
||||
FlashRegistrar onException(AbstractRouter.ExceptionHandler h);
|
||||
FlashRegistrar onNotFound(SimpleHandler.FunctionalHandler h);
|
||||
|
||||
// ── Context access ────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the {@link ExtensionContext} for this registrar (app or scope). */
|
||||
ExtensionContext ctx();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.PathUtils;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Scoped registration context for a mounted sub-router namespace.
|
||||
*
|
||||
* <p>Obtained via {@link FlashApp#mount(String, java.util.function.Consumer)}.
|
||||
* A scope has its own child {@link ExtensionContext} that inherits all services and
|
||||
* annotation processors from the parent app, so extensions like {@code @Authenticated}
|
||||
* and {@code @RolesAllowed} work identically inside a scope.
|
||||
*
|
||||
* <p>Extensions installed on a scope are scoped to that namespace and not visible
|
||||
* in the parent or sibling scopes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.mount("/api", scope -> {
|
||||
* scope.install(new RateLimitExtension());
|
||||
* scope.register(new UserHandler()); // @Authenticated auto-injected
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* scope.scan("dev.example.api.handlers");
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
public final class FlashScope implements FlashRegistrar {
|
||||
|
||||
private final AbstractRouter router;
|
||||
private final String namespace;
|
||||
private final ExtensionContext ctx;
|
||||
|
||||
/** Pending RouteHandle awaiting .with() — auto-flushed before each new registration. */
|
||||
private RouteHandle<?> pending;
|
||||
|
||||
/**
|
||||
* Package-private — only {@link FlashApp} creates scopes.
|
||||
*
|
||||
* @param router the sub-router that will receive routes registered on this scope
|
||||
* @param namespace the namespace prefix (e.g. {@code "/api"})
|
||||
* @param parentCtx the parent app's ExtensionContext — a child is created from it
|
||||
*/
|
||||
FlashScope(AbstractRouter router, String namespace, ExtensionContext parentCtx) {
|
||||
this.router = router;
|
||||
this.namespace = namespace;
|
||||
this.ctx = parentCtx.child();
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — extension installation ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Installs an extension scoped to this namespace.
|
||||
* The extension registers routes and services on this scope only.
|
||||
*/
|
||||
@Override
|
||||
public FlashScope install(FlashExtension ext) {
|
||||
flushPending();
|
||||
ext.install(this, ctx);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — route registration ───────────────────────────────────
|
||||
|
||||
@Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
|
||||
@Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
|
||||
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
|
||||
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
|
||||
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
|
||||
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
|
||||
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
|
||||
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
|
||||
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
|
||||
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
|
||||
|
||||
private RouteHandle<FlashScope> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
return track(new RouteHandle<>(this, m -> {
|
||||
String full = ns(path);
|
||||
emit(method, full, null, List.of(), m);
|
||||
router.doRegister(method, full, h, m);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. Annotation processors from the
|
||||
* parent app and any installed on this scope are applied. The {@link Route @Route}
|
||||
* path is prepended with this scope's namespace automatically.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or when the scope consumer returns.
|
||||
*/
|
||||
@Override
|
||||
public RouteHandle<FlashScope> register(RequestHandler handler) {
|
||||
return track(new RouteHandle<>(this, explicit -> {
|
||||
List<Middleware> injected = ctx.processors().stream()
|
||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||
.toList();
|
||||
Middleware[] all = injected.isEmpty()
|
||||
? explicit
|
||||
: Stream.concat(injected.stream(), Arrays.stream(explicit)).toArray(Middleware[]::new);
|
||||
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||
if (annotation != null) {
|
||||
String full = ns(annotation.path());
|
||||
emit(annotation.method(), full, handler.getClass(), injected, explicit);
|
||||
router.doRegister(annotation.method(), full, (RequestHandler) handler, all);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for {@link RequestHandler} subclasses annotated with
|
||||
* {@link Route @Route}. Each is instantiated via its no-arg constructor and registered
|
||||
* with this scope's namespace prefix and annotation processors applied.
|
||||
*/
|
||||
@Override
|
||||
public FlashScope scan(String packageName) {
|
||||
flushPending();
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashScope onException(AbstractRouter.ExceptionHandler h) {
|
||||
flushPending();
|
||||
router.onException(h);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashScope onNotFound(SimpleHandler.FunctionalHandler h) {
|
||||
flushPending();
|
||||
router.onNotFound(h);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExtensionContext ctx() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ensures any pending route is registered when the scope consumer returns. */
|
||||
void flush() {
|
||||
flushPending();
|
||||
}
|
||||
|
||||
/** Returns the sub-router for GlobalRouter to mount. */
|
||||
AbstractRouter router() {
|
||||
return router;
|
||||
}
|
||||
|
||||
/** Prepends this scope's namespace to the given path. */
|
||||
private String ns(String path) {
|
||||
return namespace + PathUtils.sanitize(path);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static RequestHandler instantiate(Class<?> cls) {
|
||||
try {
|
||||
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
|
||||
" — ensure it has a public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a {@link RouteEvent} to all {@link RouteListener}s visible from this scope's context.
|
||||
* Parent-level listeners (registered on the app) are included via context inheritance.
|
||||
* No-op if no listener has been registered. Never called on the request hot-path.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void emit(HttpMethod method, String path, Class<?> handlerClass,
|
||||
List<Middleware> injected, Middleware[] explicit) {
|
||||
List<RouteListener> listeners = ctx.routeListeners();
|
||||
if (listeners.isEmpty()) return;
|
||||
Middleware[] routerMws = router.routerMiddlewares();
|
||||
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
|
||||
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
|
||||
RouteEvent event = new RouteEvent(method, path, namespace,
|
||||
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* Minimal classpath scanner used by {@link FlashApp#scan} and {@link FlashScope#scan}.
|
||||
* Finds all classes in a package that extend {@link RequestHandler} and carry {@link Route @Route}.
|
||||
* Supports both exploded directories (development) and fat JARs (deployment).
|
||||
*/
|
||||
final class PackageScanner {
|
||||
|
||||
private PackageScanner() {}
|
||||
|
||||
/**
|
||||
* Returns all {@link RequestHandler} subclasses in {@code packageName} that carry
|
||||
* {@link Route @Route} and have a public no-arg constructor.
|
||||
*/
|
||||
static List<Class<?>> findHandlers(String packageName) {
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<?>> result = new ArrayList<>();
|
||||
try {
|
||||
Enumeration<URL> resources = cl.getResources(resourcePath);
|
||||
while (resources.hasMoreElements()) {
|
||||
URL url = resources.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, result);
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = url.getPath();
|
||||
// jar:file:/path/to/app.jar!/com/example → /path/to/app.jar
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, packageName, cl, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to scan package: " + packageName, e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl, List<Class<?>> result) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, result);
|
||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||
tryLoad(className, cl, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void scanJar(JarFile jar, String resourcePath, String packageName,
|
||||
ClassLoader cl, List<Class<?>> result) {
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} for anonymous/synthetic class files whose name segment after
|
||||
* the last {@code $} starts with a digit (e.g. {@code Foo$1.class},
|
||||
* {@code Foo$$Lambda$14.class}). Named static nested classes like
|
||||
* {@code Outer$Inner.class} return {@code false} and are eligible for scanning.
|
||||
*/
|
||||
private static boolean isAnonymous(String fileName) {
|
||||
int dollar = fileName.lastIndexOf('$');
|
||||
if (dollar < 0) return false;
|
||||
// skip past any extra '$' (lambda desugaring may produce '$$Lambda$...')
|
||||
int next = dollar + 1;
|
||||
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
||||
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
||||
}
|
||||
|
||||
private static void tryLoad(String className, ClassLoader cl, List<Class<?>> result) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (RequestHandler.class.isAssignableFrom(cls)
|
||||
&& cls.isAnnotationPresent(Route.class)
|
||||
&& !java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) {
|
||||
cls.getDeclaredConstructor(); // verify no-arg constructor exists
|
||||
result.add(cls);
|
||||
}
|
||||
} catch (Exception | Error ignored) {
|
||||
// Skip classes that cannot be loaded or don't meet criteria
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a route captured at registration time.
|
||||
*
|
||||
* <p>Emitted by {@link FlashApp} and {@link FlashScope} to every registered
|
||||
* {@link RouteListener} before the route is compiled into the routing engine.
|
||||
* All information is derived automatically — no annotations or declarations
|
||||
* are required from the developer.
|
||||
*
|
||||
* <h3>Middleware chain</h3>
|
||||
* {@link #middlewareChain} lists the {@link Middleware} classes outermost-first:
|
||||
* <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>
|
||||
* </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.).
|
||||
*
|
||||
* @param method HTTP method for this route
|
||||
* @param path full path as declared (including namespace prefix for scoped routes)
|
||||
* @param namespace namespace prefix of the router that owns this route ({@code "/"} for root)
|
||||
* @param routerType simple class name of the owning router implementation
|
||||
* @param handlerClass concrete handler class, or {@code null} for anonymous lambda handlers
|
||||
* @param middlewareChain ordered middleware classes, outermost first
|
||||
*/
|
||||
public record RouteEvent(
|
||||
HttpMethod method,
|
||||
String path,
|
||||
String namespace,
|
||||
String routerType,
|
||||
Class<?> handlerClass,
|
||||
List<Class<? extends Middleware>> middlewareChain
|
||||
) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
/**
|
||||
* Observer notified once for each route registered on a {@link FlashApp} or {@link FlashScope}.
|
||||
*
|
||||
* <p>Register via {@link ExtensionContext#addRouteListener}. The listener is called
|
||||
* <em>once per route at boot time</em>, before the route is handed to the routing engine.
|
||||
* There is zero overhead on the request hot-path.
|
||||
*
|
||||
* <p>Intended for extensions that need to introspect the route graph (e.g. a route viewer,
|
||||
* OpenAPI schema builder, etc.) without polluting the routing or middleware infrastructure.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RouteListener {
|
||||
|
||||
/**
|
||||
* Called once for every registered route, in registration order.
|
||||
*
|
||||
* @param event immutable snapshot of the route's metadata at registration time
|
||||
*/
|
||||
void onRoute(RouteEvent event);
|
||||
}
|
||||
@@ -99,6 +99,37 @@ public class Response {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 302 Found redirect. Clears the body, sets status and {@code Location} header.
|
||||
* Encoded once at call time; zero-alloc on the write path.
|
||||
*
|
||||
* <pre>{@code
|
||||
* return res.redirect("/login");
|
||||
* }</pre>
|
||||
*/
|
||||
public Response redirect(String url) {
|
||||
return redirect(HttpStatus.FOUND, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY},
|
||||
* {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308)
|
||||
* when semantics matter.
|
||||
*
|
||||
* <pre>{@code
|
||||
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
|
||||
* }</pre>
|
||||
*/
|
||||
public Response redirect(HttpStatus status, String url) {
|
||||
this.statusCode = status.code();
|
||||
this.statusBytes = status.bytes();
|
||||
this.body = null;
|
||||
this.stream = null;
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add(("Location: " + url + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
||||
public Response header(String name, String value) {
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
|
||||
@@ -17,11 +17,15 @@ import java.nio.charset.StandardCharsets;
|
||||
*
|
||||
* <p>Router-level {@link Middleware middlewares} are passed at construction time and
|
||||
* pre-fused into a single wrapper applied to every handler registered on this router.
|
||||
* Handler-level middlewares are passed to the {@link #register} / {@link #get} family of
|
||||
* methods and wrapped <em>inside</em> the router-level chain.
|
||||
* Handler-level middlewares are passed to {@link #doRegister} and wrapped
|
||||
* <em>inside</em> the router-level chain.
|
||||
*
|
||||
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain
|
||||
* {@link RequestHandler} call with zero allocation and zero lookup overhead.
|
||||
*
|
||||
* <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} or
|
||||
* {@link dev.relism.extension.FlashScope} — they are the public registration API.
|
||||
* {@link #doRegister} is an infrastructure method for those entry points.
|
||||
*/
|
||||
public abstract class AbstractRouter {
|
||||
|
||||
@@ -35,7 +39,14 @@ public abstract class AbstractRouter {
|
||||
* Pre-fused router-level middleware, or {@code null} when none were registered.
|
||||
* A single null-check in {@link #compile} is the only cost when no router middleware exists.
|
||||
*/
|
||||
private final Middleware routerMiddleware;
|
||||
private final Middleware routerMiddleware;
|
||||
|
||||
/**
|
||||
* Raw router-level middleware array, kept for route event emission by
|
||||
* {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}.
|
||||
* Never mutated after construction. Not used on the hot-path.
|
||||
*/
|
||||
private final Middleware[] rawRouterMiddlewares;
|
||||
|
||||
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
|
||||
res.setStatusCode(404);
|
||||
@@ -56,15 +67,25 @@ public abstract class AbstractRouter {
|
||||
* @param middlewares zero or more middlewares applied to every handler on this router
|
||||
*/
|
||||
protected AbstractRouter(Middleware... middlewares) {
|
||||
this.rawRouterMiddlewares = middlewares;
|
||||
this.routerMiddleware = middlewares.length == 0 ? null
|
||||
: middlewares.length == 1 ? middlewares[0]
|
||||
: Middleware.of(middlewares);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw router-level middleware array as passed at construction.
|
||||
* Used by {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}
|
||||
* to populate {@link dev.relism.extension.RouteEvent#middlewareChain()}.
|
||||
* Never mutated; never called on the hot-path.
|
||||
*/
|
||||
public Middleware[] routerMiddlewares() { return rawRouterMiddlewares; }
|
||||
|
||||
// ── Internal wiring ───────────────────────────────────────────────────────
|
||||
|
||||
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
||||
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
||||
|
||||
void setNamespace(String namespace) {
|
||||
this.namespace = namespace;
|
||||
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
|
||||
@@ -81,10 +102,8 @@ public abstract class AbstractRouter {
|
||||
*/
|
||||
private RequestHandler compile(RequestHandler handler, Middleware[] handlerMiddlewares) {
|
||||
RequestHandler compiled = handler;
|
||||
// handler-level: wrap from innermost outward; box FunctionalHandler → SimpleHandler once per layer
|
||||
for (int i = handlerMiddlewares.length - 1; i >= 0; i--)
|
||||
compiled = new SimpleHandler(handlerMiddlewares[i].wrap(compiled));
|
||||
// router-level: outermost wrapper, executes first
|
||||
if (routerMiddleware != null)
|
||||
compiled = new SimpleHandler(routerMiddleware.wrap(compiled));
|
||||
return compiled;
|
||||
@@ -102,11 +121,12 @@ public abstract class AbstractRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Internal registration (package-private) ───────────────────────────────
|
||||
// ── Infrastructure registration ───────────────────────────────────────────
|
||||
// Used by FlashApp and FlashScope. Not part of the public user-facing API.
|
||||
|
||||
/**
|
||||
* Registers a lambda handler immediately with a pre-built middleware array.
|
||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
||||
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
|
||||
*/
|
||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
|
||||
@@ -116,7 +136,8 @@ public abstract class AbstractRouter {
|
||||
|
||||
/**
|
||||
* Registers a class-based handler immediately with a pre-built middleware array.
|
||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
||||
* Reads {@link Route @Route} for method and path.
|
||||
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
|
||||
*/
|
||||
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
|
||||
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||
@@ -126,42 +147,15 @@ public abstract class AbstractRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Lambda handler registration ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Begins registration of a lambda handler. Call {@link RouteHandle#with} on the
|
||||
* returned handle to supply middlewares (if any) and complete the registration.
|
||||
*
|
||||
* <pre>{@code
|
||||
* router.get("/ping", (req, res) -> "pong").with();
|
||||
* router.get("/admin", adminHandler).with(auth, logging);
|
||||
* }</pre>
|
||||
* Registers a class-based handler with an explicit method and path (ignoring the
|
||||
* {@link Route @Route} annotation's path). Used by {@link dev.relism.extension.FlashScope}
|
||||
* to prepend the scope's namespace prefix.
|
||||
* Infrastructure method — use {@link dev.relism.extension.FlashScope} instead.
|
||||
*/
|
||||
public RouteHandle<AbstractRouter> get (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.GET, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> post (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.POST, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> put (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PUT, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> delete (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.DELETE, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> patch (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PATCH, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> options(String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.OPTIONS, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> head (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.HEAD, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> trace (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.TRACE, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> connect(String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.CONNECT, path, h, m)); }
|
||||
public RouteHandle<AbstractRouter> purge (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PURGE, path, h, m)); }
|
||||
|
||||
// ── Class-based handler registration ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. The class must carry a
|
||||
* {@link Route @Route} annotation. Call {@link RouteHandle#with} to supply
|
||||
* optional extra middlewares and complete the registration.
|
||||
*
|
||||
* <pre>{@code
|
||||
* router.register(new BlogHandler()).with();
|
||||
* router.register(new AdminHandler()).with(logging);
|
||||
* }</pre>
|
||||
*/
|
||||
public RouteHandle<AbstractRouter> register(RequestHandler handler) {
|
||||
return new RouteHandle<>(this, m -> doRegister(handler, m));
|
||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||
RequestHandler handler, Middleware[] middlewares) {
|
||||
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
|
||||
}
|
||||
|
||||
// ── Routing ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user