pre-major refactoring + ext api.
This commit is contained in:
@@ -125,18 +125,37 @@ public class HttpServer {
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer register(RequestHandler handler, Middleware... m) { globalRouter.register(handler, m); return this; }
|
||||
// ── Direct registration (called by FlashApp via RouteHandle.with()) ──────
|
||||
|
||||
public HttpServer get (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.get (path, h, m); return this; }
|
||||
public HttpServer post (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.post (path, h, m); return this; }
|
||||
public HttpServer put (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.put (path, h, m); return this; }
|
||||
public HttpServer delete (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.delete (path, h, m); return this; }
|
||||
public HttpServer patch (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.patch (path, h, m); return this; }
|
||||
public HttpServer options(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.options(path, h, m); return this; }
|
||||
public HttpServer head (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.head (path, h, m); return this; }
|
||||
public HttpServer trace (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.trace (path, h, m); return this; }
|
||||
public HttpServer connect(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.connect(path, h, m); return this; }
|
||||
public HttpServer purge (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { globalRouter.purge (path, h, m); return this; }
|
||||
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)); }
|
||||
|
||||
|
||||
private void process(Socket socket) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.relism.exceptions;
|
||||
|
||||
/**
|
||||
* Runtime exception carrying an HTTP status code. Extensions map this to a
|
||||
* structured JSON error response via the global exception handler installed by
|
||||
* {@code flash-ext-jackson}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* throw HttpException.notFound("Blog");
|
||||
* throw new HttpException(422, "Validation failed: title is required");
|
||||
* }</pre>
|
||||
*/
|
||||
public class HttpException extends RuntimeException {
|
||||
|
||||
private final int status;
|
||||
|
||||
public HttpException(int status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int status() { return status; }
|
||||
|
||||
// ── Factory methods ───────────────────────────────────────────────────────
|
||||
|
||||
public static HttpException badRequest(String message) {
|
||||
return new HttpException(400, message);
|
||||
}
|
||||
|
||||
public static HttpException unauthorized() {
|
||||
return new HttpException(401, "Unauthorized");
|
||||
}
|
||||
|
||||
public static HttpException forbidden() {
|
||||
return new HttpException(403, "Forbidden");
|
||||
}
|
||||
|
||||
public static HttpException notFound(String what) {
|
||||
return new HttpException(404, what + " not found");
|
||||
}
|
||||
|
||||
public static HttpException conflict(String message) {
|
||||
return new HttpException(409, message);
|
||||
}
|
||||
|
||||
public static HttpException unprocessable(String message) {
|
||||
return new HttpException(422, message);
|
||||
}
|
||||
|
||||
public static HttpException internal(String message) {
|
||||
return new HttpException(500, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
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
|
||||
* the handler is compiled into the router. Returning an empty list is always
|
||||
* valid — processors may also use the call purely for side effects
|
||||
* (e.g. collecting OpenAPI metadata).
|
||||
*
|
||||
* <p>Register processors via {@link ExtensionContext#addAnnotationProcessor}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AnnotationProcessor {
|
||||
List<Middleware> process(Class<? extends RequestHandler> handlerClass);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Shared registry passed to every extension during {@link FlashExtension#install}.
|
||||
* Extensions use it in two ways:
|
||||
* <ol>
|
||||
* <li><b>Service sharing</b> — provide/require typed objects (e.g. {@code ObjectMapper},
|
||||
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
|
||||
* <li><b>Annotation processing</b> — register {@link AnnotationProcessor}s that
|
||||
* {@link FlashApp} calls for every handler, injecting middleware derived from
|
||||
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
|
||||
* </ol>
|
||||
*/
|
||||
public class ExtensionContext {
|
||||
|
||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
|
||||
// ── Service registry ─────────────────────────────────────────────────────
|
||||
|
||||
/** Stores {@code instance} under {@code type} for retrieval by other extensions. */
|
||||
public <T> void provide(Class<T> type, T instance) {
|
||||
registry.put(type, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the service registered under {@code type}.
|
||||
* Throws {@link IllegalStateException} if not present — install order matters.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T require(Class<T> type) {
|
||||
T val = (T) registry.get(type);
|
||||
if (val == null)
|
||||
throw new IllegalStateException(
|
||||
"Extension dependency not found: " + type.getSimpleName() +
|
||||
" — install the required extension first");
|
||||
return val;
|
||||
}
|
||||
|
||||
/** Returns the service under {@code type}, or empty if not installed. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> Optional<T> find(Class<T> type) {
|
||||
return Optional.ofNullable((T) registry.get(type));
|
||||
}
|
||||
|
||||
// ── Annotation processors ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers an {@link AnnotationProcessor}. Called by extensions during
|
||||
* {@link FlashExtension#install}. Processors are invoked in registration order.
|
||||
*/
|
||||
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
||||
processors.add(processor);
|
||||
}
|
||||
|
||||
/** Returns an unmodifiable view of all registered processors. */
|
||||
List<AnnotationProcessor> processors() {
|
||||
return Collections.unmodifiableList(processors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.HttpServer;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.Middleware;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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:
|
||||
*
|
||||
* <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();
|
||||
* }</pre>
|
||||
*/
|
||||
public class FlashApp {
|
||||
|
||||
private final HttpServer server;
|
||||
private final ExtensionContext ctx = new ExtensionContext();
|
||||
|
||||
/** The last returned RouteHandle that has not yet been registered. */
|
||||
private RouteHandle<?> pending;
|
||||
|
||||
private FlashApp(HttpServer server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public static FlashApp of(HttpServer server) {
|
||||
return new FlashApp(server);
|
||||
}
|
||||
|
||||
// ── 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}.
|
||||
*/
|
||||
private void flushPending() {
|
||||
if (pending != null) {
|
||||
pending.ensureRegistered();
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
private <P> RouteHandle<P> pending(RouteHandle<P> handle) {
|
||||
flushPending();
|
||||
pending = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
// ── Extension installation ────────────────────────────────────────────────
|
||||
|
||||
public FlashApp install(FlashExtension ext) {
|
||||
flushPending();
|
||||
ext.install(this, ctx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Exposes the context so callers can retrieve services installed by extensions. */
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
public CompletableFuture<Void> start() {
|
||||
flushPending();
|
||||
return server.start();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> stop() {
|
||||
return server.stop();
|
||||
}
|
||||
|
||||
/** Direct access to the underlying server for advanced use cases. */
|
||||
public HttpServer server() {
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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.
|
||||
*
|
||||
* <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));
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OpenApiExtension("/openapi.json"))
|
||||
* .install(new OidcExtension(OidcConfig.fromEnv()));
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FlashExtension {
|
||||
void install(FlashApp app, ExtensionContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.exceptions.HttpException;
|
||||
|
||||
/**
|
||||
* Typed accessors for request parameters. All methods throw
|
||||
* {@link HttpException} (400) on missing or malformed input, so handlers
|
||||
* do not need to write per-field validation boilerplate.
|
||||
*
|
||||
* <pre>{@code
|
||||
* int page = RequestHelper.queryInt(req, "page", 1, 100);
|
||||
* long id = RequestHelper.paramLong(req, "id");
|
||||
* String q = RequestHelper.queryRequired(req, "q");
|
||||
* }</pre>
|
||||
*/
|
||||
public final class RequestHelper {
|
||||
|
||||
private RequestHelper() {}
|
||||
|
||||
// ── Query parameters ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the query param as an int, or {@code defaultValue} if absent.
|
||||
* Caps the result at {@code max}. Throws 400 on non-integer input.
|
||||
*/
|
||||
public static int queryInt(Request req, String name, int defaultValue, int max) {
|
||||
String v = req.query(name);
|
||||
if (v == null || v.isEmpty()) return defaultValue;
|
||||
try {
|
||||
return Math.min(Integer.parseInt(v), max);
|
||||
} catch (NumberFormatException e) {
|
||||
throw HttpException.badRequest("Invalid integer for query param '" + name + "': " + v);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query param value. Throws 400 if absent or blank.
|
||||
*/
|
||||
public static String queryRequired(Request req, String name) {
|
||||
String v = req.query(name);
|
||||
if (v == null || v.isBlank())
|
||||
throw HttpException.badRequest("Missing required query param: " + name);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query param, or {@code null} if absent.
|
||||
*/
|
||||
public static String queryOptional(Request req, String name) {
|
||||
return req.query(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query param as a long, or {@code defaultValue} if absent.
|
||||
* Throws 400 on non-long input.
|
||||
*/
|
||||
public static long queryLong(Request req, String name, long defaultValue) {
|
||||
String v = req.query(name);
|
||||
if (v == null || v.isEmpty()) return defaultValue;
|
||||
try {
|
||||
return Long.parseLong(v);
|
||||
} catch (NumberFormatException e) {
|
||||
throw HttpException.badRequest("Invalid long for query param '" + name + "': " + v);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path parameters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the path parameter as a long. Throws 400 if absent or non-long.
|
||||
*/
|
||||
public static long paramLong(Request req, String name) {
|
||||
String v = req.param(name);
|
||||
if (v == null)
|
||||
throw HttpException.badRequest("Missing path param: " + name);
|
||||
try {
|
||||
return Long.parseLong(v);
|
||||
} catch (NumberFormatException e) {
|
||||
throw HttpException.badRequest("Invalid long for path param '" + name + "': " + v);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path parameter as an int. Throws 400 if absent or non-int.
|
||||
*/
|
||||
public static int paramInt(Request req, String name) {
|
||||
String v = req.param(name);
|
||||
if (v == null)
|
||||
throw HttpException.badRequest("Missing path param: " + name);
|
||||
try {
|
||||
return Integer.parseInt(v);
|
||||
} catch (NumberFormatException e) {
|
||||
throw HttpException.badRequest("Invalid int for path param '" + name + "': " + v);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path parameter as a String. Throws 400 if absent.
|
||||
*/
|
||||
public static String paramRequired(Request req, String name) {
|
||||
String v = req.param(name);
|
||||
if (v == null)
|
||||
throw HttpException.badRequest("Missing path param: " + name);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,6 @@ public abstract class AbstractRouter {
|
||||
return compiled;
|
||||
}
|
||||
|
||||
private static final Middleware[] NO_MIDDLEWARES = new Middleware[0];
|
||||
|
||||
// ── Error handler configuration ───────────────────────────────────────────
|
||||
|
||||
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
@@ -104,38 +102,23 @@ public abstract class AbstractRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Lambda handler registration ───────────────────────────────────────────
|
||||
// ── Internal registration (package-private) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers a lambda handler with optional handler-level middlewares.
|
||||
* Router-level middlewares are applied automatically on top.
|
||||
* Registers a lambda handler immediately with a pre-built middleware array.
|
||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
||||
*/
|
||||
public AbstractRouter register(HttpMethod method, String path,
|
||||
SimpleHandler.FunctionalHandler handler,
|
||||
Middleware... middlewares) {
|
||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
|
||||
return addRoute(method, PathUtils.sanitize(path),
|
||||
compile(new SimpleHandler(handler), middlewares));
|
||||
}
|
||||
|
||||
public AbstractRouter get (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.GET, path, h, m); }
|
||||
public AbstractRouter post (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.POST, path, h, m); }
|
||||
public AbstractRouter put (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PUT, path, h, m); }
|
||||
public AbstractRouter delete (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.DELETE, path, h, m); }
|
||||
public AbstractRouter patch (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PATCH, path, h, m); }
|
||||
public AbstractRouter options(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.OPTIONS, path, h, m); }
|
||||
public AbstractRouter head (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.HEAD, path, h, m); }
|
||||
public AbstractRouter trace (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.TRACE, path, h, m); }
|
||||
public AbstractRouter connect(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.CONNECT, path, h, m); }
|
||||
public AbstractRouter purge (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PURGE, path, h, m); }
|
||||
|
||||
// ── Class-based handler registration ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers a class-based handler with optional handler-level middlewares.
|
||||
* The class must carry a {@link Route @Route} annotation declaring method and path.
|
||||
* Router-level middlewares are applied automatically on top.
|
||||
* Registers a class-based handler immediately with a pre-built middleware array.
|
||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
||||
*/
|
||||
public AbstractRouter register(RequestHandler handler, Middleware... middlewares) {
|
||||
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
|
||||
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||
if (annotation != null)
|
||||
addRoute(annotation.method(), annotation.path(),
|
||||
@@ -143,6 +126,44 @@ 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>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Routing ───────────────────────────────────────────────────────────────
|
||||
|
||||
public abstract RequestHandler route(Request request);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,11 @@ class HttpServerConcurrencyTest {
|
||||
.build();
|
||||
|
||||
server = new HttpServer(config);
|
||||
server.get("/ping", (req, res) -> "pong");
|
||||
server.get("/ping", (req, res) -> "pong").with();
|
||||
server.post("/echo", (req, res) -> {
|
||||
byte[] body = req.body().bytes();
|
||||
return new Response(200, body, ContentType.TEXT_PLAIN);
|
||||
});
|
||||
}).with();
|
||||
|
||||
server.start().get(5, TimeUnit.SECONDS);
|
||||
|
||||
@@ -187,7 +187,7 @@ class HttpServerConcurrencyTest {
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final int idx = i;
|
||||
freshServer.get("/route" + idx, (req, res) -> "handler" + idx);
|
||||
freshServer.get("/route" + idx, (req, res) -> "handler" + idx).with();
|
||||
}
|
||||
|
||||
freshServer.start().get(5, TimeUnit.SECONDS);
|
||||
|
||||
@@ -37,31 +37,31 @@ class HttpServerTest {
|
||||
server = new HttpServer(config);
|
||||
|
||||
// Setup some routes
|
||||
server.get("/api/ping", (req, res) -> "pong");
|
||||
|
||||
server.get("/api/ping", (req, res) -> "pong").with();
|
||||
|
||||
server.post("/api/echo", (req, res) -> {
|
||||
byte[] body = req.body().bytes();
|
||||
return res.status(201).body(body); // echo body & change status
|
||||
});
|
||||
}).with();
|
||||
|
||||
server.get("/api/crash", (req, res) -> {
|
||||
throw new RuntimeException("Simulated Crash");
|
||||
});
|
||||
}).with();
|
||||
|
||||
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
|
||||
server.get("/api/stream", (req, res) ->
|
||||
res.stream(new ByteArrayInputStream(streamData), streamData.length));
|
||||
res.stream(new ByteArrayInputStream(streamData), streamData.length)).with();
|
||||
|
||||
server.get("/api/chunked-out", (req, res) ->
|
||||
res.chunked(new ByteArrayInputStream(streamData)));
|
||||
res.chunked(new ByteArrayInputStream(streamData))).with();
|
||||
|
||||
server.get("/api/custom-header", (req, res) ->
|
||||
res.body("ok").header("X-Flash", "works"));
|
||||
res.body("ok").header("X-Flash", "works")).with();
|
||||
|
||||
server.post("/api/chunked-in", (req, res) -> {
|
||||
byte[] body = req.body().bytes();
|
||||
return res.body(body);
|
||||
});
|
||||
}).with();
|
||||
|
||||
server.start().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class AbstractRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Route(method = "POST", path = "/profile")
|
||||
@Route(method = dev.relism.http.HttpMethod.POST, path = "/profile")
|
||||
static class ProfileHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -68,18 +68,18 @@ class AbstractRouterTest {
|
||||
DummyRouter router = new DummyRouter();
|
||||
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
|
||||
|
||||
router.get("users/", func);
|
||||
router.get("users/", func).with();
|
||||
assertEquals(HttpMethod.GET, router.lastAddedMethod);
|
||||
assertEquals("/users", router.lastAddedPath);
|
||||
assertTrue(router.lastAddedHandler instanceof SimpleHandler);
|
||||
|
||||
router.post("/items", func);
|
||||
router.post("/items", func).with();
|
||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
||||
|
||||
router.put("update", func);
|
||||
router.put("update", func).with();
|
||||
assertEquals(HttpMethod.PUT, router.lastAddedMethod);
|
||||
|
||||
router.delete("//delete//", func);
|
||||
router.delete("//delete//", func).with();
|
||||
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
|
||||
assertEquals("/delete", router.lastAddedPath);
|
||||
}
|
||||
@@ -90,9 +90,9 @@ class AbstractRouterTest {
|
||||
void register_annotatedHandler_addsRoute() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
ProfileHandler handler = new ProfileHandler();
|
||||
|
||||
router.register(handler);
|
||||
|
||||
|
||||
router.register(handler).with();
|
||||
|
||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
||||
assertEquals("/profile", router.lastAddedPath);
|
||||
assertEquals(handler, router.lastAddedHandler);
|
||||
@@ -101,7 +101,7 @@ class AbstractRouterTest {
|
||||
@Test
|
||||
void register_unannotatedHandler_doesNothing() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
router.register(new UnannotatedHandler());
|
||||
router.register(new UnannotatedHandler()).with();
|
||||
assertNull(router.lastAddedMethod); // Nothing added
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class GlobalRouterTest {
|
||||
GlobalRouter global = new GlobalRouter();
|
||||
RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal");
|
||||
|
||||
global.get("/hello", (req, res) -> "internal");
|
||||
global.get("/hello", (req, res) -> "internal").with();
|
||||
// We know it routes to internal. Let's send a request.
|
||||
RequestHandler resolved = global.route(mockRequest("/hello"));
|
||||
assertNotNull(resolved);
|
||||
|
||||
+4
-4
@@ -34,8 +34,8 @@ class FastPathRouterImplTest {
|
||||
void route_lazyCompilationAndMatch() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/a", (req, res) -> "A");
|
||||
router.post("/b", (req, res) -> "B");
|
||||
router.get("/a", (req, res) -> "A").with();
|
||||
router.post("/b", (req, res) -> "B").with();
|
||||
|
||||
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
|
||||
assertNotNull(res1);
|
||||
@@ -49,7 +49,7 @@ class FastPathRouterImplTest {
|
||||
@Test
|
||||
void route_noMatch_returnsNull() {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
router.get("/a", (req, res) -> "A");
|
||||
router.get("/a", (req, res) -> "A").with();
|
||||
|
||||
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
|
||||
// Wrong method
|
||||
@@ -60,7 +60,7 @@ class FastPathRouterImplTest {
|
||||
void route_extractsPathParams() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract");
|
||||
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract").with();
|
||||
|
||||
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
|
||||
RequestHandler handler = router.route(request);
|
||||
|
||||
Reference in New Issue
Block a user