diff --git a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java index f9451bb..889b6aa 100644 --- a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java @@ -27,22 +27,56 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +/** + * The core inbound channel handler that processes every aggregated HTTP request. + * + *
For each request it, in order: detects and performs WebSocket upgrades (when a WebSocket + * router is configured), answers CORS preflight requests, enforces rate limits, resolves the + * route via the {@link Router}, runs middlewares and the matched handler, and finally writes the + * response with CORS and rate-limit headers applied.
+ * + *Blocking handler logic runs on a virtual-thread executor rather than on the Netty event + * loop, so handlers may perform blocking work without stalling I/O. WebSocket upgrades, by + * contrast, mutate the pipeline and are handled inline on the event loop.
+ */ public final class HttpRequestHandler extends SimpleChannelInboundHandlerAt start-up it selects the most efficient available transport — {@code epoll} on + * Linux, {@code kqueue} on macOS/BSD, or the portable NIO transport otherwise — and wires + * up the Netty channel pipeline (codec, aggregator and the {@link HttpRequestHandler}). The + * {@link #start()} call blocks until the server channel is closed.
+ */ public final class HttpServer { + /** TCP port the server binds to. */ private final int port; + /** Router resolving requests to handlers. */ private final Router router; + /** Optional CORS handler; {@code null} disables CORS handling. */ private CorsHandler cors; + /** Optional rate-limit gate; {@code null} disables rate limiting. */ private RateLimitGate gate; + /** Optional WebSocket router; {@code null} disables WebSocket support. */ private WebSocketRouter wsRouter; + /** WebSocket configuration; only used when {@link #wsRouter} is set. */ private WebSocketConfig wsConfig; + /** + * Creates a server bound to a port and router. Use {@link #builder(int, Router)} instead of + * calling this directly. + * + * @param port the TCP port to bind + * @param router the router resolving requests + */ private HttpServer(int port, Router router) { this.port = port; this.router = router; } + /** + * Starts building a server for the given port and router. + * + * @param port the TCP port to bind + * @param router the router resolving requests + * @return a new, configurable {@code HttpServer} instance + */ public static HttpServer builder(int port, Router router) { return new HttpServer(port, router); } + /** + * Attaches a CORS handler that decorates responses and answers preflight requests. + * + * @param cors the CORS handler to use + * @return this instance, for fluent chaining + */ public HttpServer withCorsHandler(CorsHandler cors) { this.cors = cors; return this; } + /** + * Attaches a rate-limit gate that throttles incoming requests. + * + * @param gate the rate-limit gate to use + * @return this instance, for fluent chaining + */ public HttpServer withRateLimitGate(RateLimitGate gate) { this.gate = gate; return this; } + /** + * Enables WebSocket support with default configuration. + * + * @param wsRouter the WebSocket router resolving upgrade paths to handlers + * @return this instance, for fluent chaining + * @see #withWebSockets(WebSocketRouter, WebSocketConfig) + */ public HttpServer withWebSockets(WebSocketRouter wsRouter) { return withWebSockets(wsRouter, WebSocketConfig.defaults()); } + /** + * Enables WebSocket support with explicit configuration. + * + * @param wsRouter the WebSocket router resolving upgrade paths to handlers + * @param wsConfig the WebSocket configuration (frame sizes, timeouts, origins, ...) + * @return this instance, for fluent chaining + */ public HttpServer withWebSockets(WebSocketRouter wsRouter, WebSocketConfig wsConfig) { this.wsRouter = wsRouter; this.wsConfig = wsConfig; return this; } + /** + * Starts the server using the configuration accumulated on this instance and blocks until + * the server channel closes. + * + * @throws InterruptedException if the binding or close-future wait is interrupted + */ public void start() throws InterruptedException { start(port, router, cors, gate, wsRouter, wsConfig); } + /** + * Starts a server without WebSocket support. Convenience overload of + * {@link #start(int, Router, CorsHandler, RateLimitGate, WebSocketRouter, WebSocketConfig)}. + * + * @param port the TCP port to bind + * @param router the router resolving requests + * @param cors the CORS handler, or {@code null} to disable CORS + * @param gate the rate-limit gate, or {@code null} to disable rate limiting + * @throws InterruptedException if the binding or close-future wait is interrupted + */ public static void start(int port, Router router, CorsHandler cors, RateLimitGate gate) throws InterruptedException { start(port, router, cors, gate, null, null); } + /** + * Starts the server, selecting the best transport for the platform, configuring the Netty + * channel pipeline and binding the port. The call blocks until the server channel is closed, + * after which the event-loop groups are shut down gracefully. + * + * @param port the TCP port to bind + * @param router the router resolving requests + * @param cors the CORS handler, or {@code null} to disable CORS + * @param gate the rate-limit gate, or {@code null} to disable rate limiting + * @param wsRouter the WebSocket router, or {@code null} to disable WebSocket support + * @param wsConfig the WebSocket configuration, used only when {@code wsRouter} is non-null + * @throws InterruptedException if the binding or close-future wait is interrupted + */ public static void start(int port, Router router, CorsHandler cors, RateLimitGate gate, WebSocketRouter wsRouter, WebSocketConfig wsConfig) throws InterruptedException { diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java b/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java index 20ae8b1..e13921f 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java @@ -9,15 +9,63 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Method; +/** + * Reflective registrar that wires the routing annotations on a controller object into a + * {@link Router}. + * + *Given a controller instance, the scanner reads the optional {@link Controller} annotation + * to determine a path prefix, then walks every declared method looking for one of the + * supported route annotations ({@link Route}, {@link GET}, {@link POST}, {@link PUT}, + * {@link DELETE}, {@link PATCH} or {@link CUSTOM}). For each matching method it:
+ *This class is a stateless utility and cannot be instantiated.
+ * + * @see Controller + * @see Router + */ public final class AnnotationScanner { + /** + * Shared lookup used to unreflect controller methods into {@link MethodHandle}s. A single + * lookup is sufficient because the scanner forces accessibility on each method before + * unreflecting it. + */ private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + + /** + * The exact method type every handler must conform to: {@code void (Request, Response)}. + * Used as documentation of the contract enforced by {@link #validateSignature(Method)}. + */ private static final MethodType HANDLER_TYPE = MethodType.methodType(void.class, Request.class, Response.class); + /** + * Private constructor preventing instantiation of this stateless utility class. + */ private AnnotationScanner() { } + /** + * Scans the given controller for route annotations and registers every discovered handler + * with the supplied router. + * + *If the controller class is annotated with {@link Controller}, its value is used as a + * path prefix for all routes. Methods without a recognised route annotation are ignored. + * A line describing each registered route is printed to standard output.
+ * + * @param router the router to register the discovered handlers with + * @param controller the controller instance whose annotated methods should be registered + * @throws IllegalArgumentException if an annotated method has an invalid signature + * @throws RuntimeException if a method cannot be made accessible or unreflected + */ public static void register(Router router, Object controller) { Class> clazz = controller.getClass(); Controller ctrlAnno = clazz.getAnnotation(Controller.class); @@ -55,11 +103,27 @@ public final class AnnotationScanner { } } + /** + * Normalizes a controller-level path prefix by ensuring it starts with a single leading + * slash. + * + * @param p the raw prefix from the {@link Controller} annotation, may be {@code null} or empty + * @return the normalized prefix, or an empty string if {@code p} is {@code null} or empty + */ private static String normalizePrefix(String p) { if (p == null || p.isEmpty()) return ""; return p.startsWith("/") ? p : "/" + p; } + /** + * Extracts route metadata (HTTP method and path) from a method by inspecting the supported + * route annotations in priority order. {@link Route} is checked first, followed by the + * verb-specific annotations and finally {@link CUSTOM}. + * + * @param m the method to inspect + * @return a {@link RouteInfo} describing the route, or {@code null} if the method carries + * no recognised route annotation + */ private static RouteInfo extractRoute(Method m) { Route r = m.getAnnotation(Route.class); if (r != null) return new RouteInfo(r.method(), r.path()); @@ -75,16 +139,24 @@ public final class AnnotationScanner { DELETE del = m.getAnnotation(DELETE.class); if (del != null) return new RouteInfo("DELETE", del.value()); - + PATCH patch = m.getAnnotation(PATCH.class); if (patch != null) return new RouteInfo("PATCH", patch.value()); - + CUSTOM custom = m.getAnnotation(CUSTOM.class); if (custom != null) return new RouteInfo(custom.method(), custom.value()); return null; } + /** + * Validates that a handler method conforms to the required {@code void (Request, Response)} + * contract. + * + * @param m the method to validate + * @throws IllegalArgumentException if the method does not take exactly a {@link Request} + * and a {@link Response}, or does not return {@code void} + */ private static void validateSignature(Method m) { Class>[] params = m.getParameterTypes(); if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) { @@ -95,11 +167,23 @@ public final class AnnotationScanner { } } + /** + * Normalizes a route-level path by ensuring it starts with a single leading slash. + * + * @param p the raw path from a route annotation, may be {@code null} or empty + * @return the normalized path, or an empty string if {@code p} is {@code null} or empty + */ private static String normalizePath(String p) { if (p == null || p.isEmpty()) return ""; return p.startsWith("/") ? p : "/" + p; } + /** + * Immutable carrier for the HTTP method and path extracted from a route annotation. + * + * @param method the HTTP method name (e.g. {@code "GET"}) + * @param path the route path relative to the controller prefix + */ private record RouteInfo(String method, String path) { } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java b/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java index ed93b4b..1e1943f 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java @@ -5,9 +5,39 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to a route using a custom or non-standard HTTP method. Whereas + * {@link GET}, {@link POST} and the other verb annotations hard-code the verb, {@code @CUSTOM} + * lets the caller name the verb explicitly via {@link #method()} (for example {@code "HEAD"}, + * {@code "OPTIONS"} or a WebDAV-style verb). + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface CUSTOM { + + /** + * The HTTP method name this route responds to. Must be a value accepted by + * {@link io.netty.handler.codec.http.HttpMethod#valueOf(String)}. + * + * @return the HTTP method name + */ String method(); + + /** + * The path this route is mounted at, relative to any controller prefix. Supports + * {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/Controller.java b/src/main/java/dev/coph/nextusweb/server/annotation/Controller.java index a3d9e96..9595b22 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/Controller.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/Controller.java @@ -5,8 +5,36 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks a class as a controller: a container for HTTP handler methods that are + * discovered and wired into the {@link dev.coph.nextusweb.server.router.Router Router} at + * runtime by the {@link AnnotationScanner}. + * + *The optional {@link #value()} acts as a common path prefix that is prepended to every + * route declared inside the annotated class. For example, a controller annotated with + * {@code @Controller("/api")} whose method is annotated with {@code @GET("/users")} will be + * registered under {@code /api/users}.
+ * + *This annotation is retained at {@link RetentionPolicy#RUNTIME runtime} because the + * scanner inspects it reflectively while the application is running, and it may only be + * placed on {@link ElementType#TYPE types} (classes).
+ * + * @see AnnotationScanner + * @see Route + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Controller { + + /** + * The base path prefix that is prepended to every route declared in the annotated + * controller class. + * + *A leading slash is optional; the scanner normalizes the value so that + * {@code "api"} and {@code "/api"} behave identically. The default empty string means + * the controller contributes no prefix and its routes are registered as-is.
+ * + * @return the path prefix, or an empty string for no prefix + */ String value() default ""; -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/DELETE.java b/src/main/java/dev/coph/nextusweb/server/annotation/DELETE.java index 87d69e0..c7a3801 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/DELETE.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/DELETE.java @@ -5,8 +5,29 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to an HTTP {@code DELETE} route. This is a convenience shorthand + * for {@link Route @Route(method = "DELETE", path = ...)}. + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface DELETE { + + /** + * The path this {@code DELETE} route is mounted at, relative to any controller prefix. + * Supports {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/GET.java b/src/main/java/dev/coph/nextusweb/server/annotation/GET.java index 41bbfd7..7ab6a35 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/GET.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/GET.java @@ -5,8 +5,29 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to an HTTP {@code GET} route. This is a convenience shorthand for + * {@link Route @Route(method = "GET", path = ...)}. + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface GET { + + /** + * The path this {@code GET} route is mounted at, relative to any controller prefix. + * Supports {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java b/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java index b482450..8ab3ac7 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java @@ -5,8 +5,29 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to an HTTP {@code PATCH} route. This is a convenience shorthand + * for {@link Route @Route(method = "PATCH", path = ...)}. + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface PATCH { + + /** + * The path this {@code PATCH} route is mounted at, relative to any controller prefix. + * Supports {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/POST.java b/src/main/java/dev/coph/nextusweb/server/annotation/POST.java index 0883f2a..3f85840 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/POST.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/POST.java @@ -5,8 +5,29 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to an HTTP {@code POST} route. This is a convenience shorthand for + * {@link Route @Route(method = "POST", path = ...)}. + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface POST { + + /** + * The path this {@code POST} route is mounted at, relative to any controller prefix. + * Supports {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/PUT.java b/src/main/java/dev/coph/nextusweb/server/annotation/PUT.java index df31d97..fc95791 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/PUT.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/PUT.java @@ -5,8 +5,29 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Binds a controller method to an HTTP {@code PUT} route. This is a convenience shorthand for + * {@link Route @Route(method = "PUT", path = ...)}. + * + *The annotated method must have the signature {@code void handler(Request, Response)}, + * which the {@link AnnotationScanner} verifies during registration. The route path given by + * {@link #value()} is combined with any {@link Controller#value() controller prefix}.
+ * + *Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only + * applicable to {@link ElementType#METHOD methods}.
+ * + * @see Route + * @see AnnotationScanner + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface PUT { + + /** + * The path this {@code PUT} route is mounted at, relative to any controller prefix. + * Supports {@code {param}} path parameters and {@code *} wildcards. + * + * @return the route path + */ String value(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/Route.java b/src/main/java/dev/coph/nextusweb/server/annotation/Route.java index 5ba56af..634263e 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/Route.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/Route.java @@ -5,10 +5,41 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Generic route declaration that binds a controller method to an arbitrary HTTP method and + * path. This is the most flexible of the routing annotations: where {@link GET}, {@link POST} + * and friends hard-code the HTTP verb, {@code @Route} lets the verb be specified explicitly + * via {@link #method()}. + * + *Handler methods carrying this annotation must follow the signature + * {@code void handler(Request, Response)}; this is enforced by the {@link AnnotationScanner} + * when the route is registered.
+ * + *The annotation is retained at {@link RetentionPolicy#RUNTIME runtime} so the scanner can + * read it reflectively, and may only be placed on {@link ElementType#METHOD methods}.
+ * + * @see AnnotationScanner + * @see Controller + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Route { + + /** + * The HTTP method (verb) this route responds to, for example {@code "GET"} or + * {@code "POST"}. The value must match a name accepted by + * {@link io.netty.handler.codec.http.HttpMethod#valueOf(String)}. + * + * @return the HTTP method name + */ String method(); + /** + * The path this route is mounted at, relative to any {@link Controller#value() controller + * prefix}. Path segments wrapped in braces (e.g. {@code /users/{id}}) denote path + * parameters, and a {@code *} segment denotes a wildcard. + * + * @return the route path + */ String path(); -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java b/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java index 9f128d4..6042c72 100644 --- a/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java +++ b/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java @@ -6,16 +6,42 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +/** + * Immutable configuration describing the Cross-Origin Resource Sharing (CORS) policy the + * server enforces. Instances are created through the nested {@link Builder} and consumed by + * {@link CorsHandler} to decide which origins, methods and headers are permitted. + * + *As a safety measure the configuration forbids combining a wildcard origin + * ({@link #allowAnyOrigin()}) with {@link #allowCredentials() credentialed requests}, which + * the CORS specification disallows.
+ * + * @see CorsHandler + */ public final class CorsConfig { + /** Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}. */ private final SetBecause it allows any origin it intentionally does not enable credentials.
+ * + * @return a ready-to-use permissive configuration + */ public static CorsConfig permissive() { return builder() .anyOrigin() @@ -42,86 +76,175 @@ public final class CorsConfig { .build(); } + /** + * Creates a new, empty {@link Builder}. + * + * @return a fresh builder + */ public static Builder builder() { return new Builder(); } + /** + * Tests whether a given request origin is permitted by this policy. + * + * @param origin the {@code Origin} header value, may be {@code null} + * @return {@code true} if the origin is allowed; {@code false} for a {@code null} origin or + * one not in the allow-list (unless any origin is permitted) + */ public boolean isOriginAllowed(String origin) { if (origin == null) return false; if (allowAnyOrigin) return true; return allowedOrigins.contains(origin); } + /** + * @return the immutable set of allowed HTTP methods + */ public SetThe handler pre-computes the comma-separated header strings derived from the + * configuration (allowed methods, allowed headers, exposed headers) once at construction time + * so they need not be rebuilt for every request. It then offers two entry points:
+ *For wildcard, credential-less policies a literal {@code *} is emitted; otherwise the + * concrete origin is echoed back together with a {@code Vary: Origin} header so caches key + * on the origin. Requests without an origin or with a disallowed origin are left + * untouched.
+ * + * @param origin the request's {@code Origin} header, may be {@code null} + * @param res the response to decorate + */ public void applyHeaders(String origin, Response res) { - if (origin == null) return; + if (origin == null) return; if (!config.isOriginAllowed(origin)) return; @@ -40,12 +77,33 @@ public final class CorsHandler { res.header("Access-Control-Expose-Headers", exposedHeadersHeader); } } - + + /** + * Determines whether a request is a CORS preflight request, i.e. an {@code OPTIONS} + * request carrying an {@code Access-Control-Request-Method} header. + * + * @param method the request method + * @param headers the request headers + * @return {@code true} if the request is a preflight request + */ public boolean isPreflight(HttpMethod method, HttpHeaders headers) { return method.equals(HttpMethod.OPTIONS) && headers.contains("Access-Control-Request-Method"); } - + + /** + * Builds the response to a CORS preflight request. + * + *If the origin is missing or disallowed the response is a {@code 403 Forbidden}; + * otherwise it is a {@code 204 No Content} carrying the allowed methods and headers, the + * requested headers echoed back when no explicit allow-list is configured, and the + * {@code Access-Control-Max-Age} cache hint when configured.
+ * + * @param origin the request's {@code Origin} header, may be {@code null} + * @param requestHeaders the request's headers (used to read + * {@code Access-Control-Request-Headers}) + * @return the fully populated preflight response + */ public Response handlePreflight(String origin, HttpHeaders requestHeaders) { Response res = new Response().status(204); @@ -69,4 +127,4 @@ public final class CorsHandler { return res; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/json/JsonMapper.java b/src/main/java/dev/coph/nextusweb/server/json/JsonMapper.java index b49e32f..f355841 100644 --- a/src/main/java/dev/coph/nextusweb/server/json/JsonMapper.java +++ b/src/main/java/dev/coph/nextusweb/server/json/JsonMapper.java @@ -3,10 +3,28 @@ package dev.coph.nextusweb.server.json; import tools.jackson.databind.ObjectMapper; +/** + * Holder for the application-wide Jackson {@link ObjectMapper}. + * + *A single, pre-configured mapper instance is shared across the whole server because + * {@code ObjectMapper} is thread-safe once configured and is relatively expensive to build. + * Centralizing it here ensures every component (request parsing, response serialization, + * WebSocket payloads) uses identical serialization settings.
+ * + *This class is a static holder and cannot be instantiated.
+ */ public final class JsonMapper { + + /** + * The shared, thread-safe Jackson mapper used throughout the server for all JSON reading + * and writing. + */ public static final ObjectMapper MAPPER = tools.jackson.databind.json.JsonMapper.builder() // .addModule(new JavaTimeModule()) .build(); - + + /** + * Private constructor preventing instantiation of this static holder class. + */ private JsonMapper() {} -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java index c5b440c..d1a5c03 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java @@ -3,37 +3,89 @@ package dev.coph.nextusweb.server.ratelimit; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +/** + * A {@link RateLimiter} implementing the fixed window counter algorithm. + * + *Time is divided into consecutive windows of {@code windowMillis} length. Each key may make + * up to {@code limit} requests within a window; the counter resets to zero when a new window + * begins. This is the simplest counting strategy but can permit up to twice the limit across a + * window boundary (the "burst at the edge" problem) — see {@link SlidingWindowLimiter} + * for a smoother variant.
+ * + *Window state is held in {@link AtomicLong}s, making the limiter safe for concurrent + * use.
+ */ public final class FixedWindowLimiter implements RateLimiter { + /** Maximum number of requests permitted per window. */ private final long limit; + /** Window length in nanoseconds. */ private final long windowNanos; + /** Per-key windows, created on demand. */ private final ConcurrentHashMapLazily creates the window for {@code key} and counts this request against it.
+ */ @Override public Result tryAcquire(String key, long nowNanos) { Window w = windows.computeIfAbsent(key, k -> new Window(nowNanos)); return w.tryAcquire(nowNanos, limit, windowNanos); } + /** + * Evicts windows whose start time is older than the given age. + * + * @param olderThanNanos maximum age in nanoseconds before a window is removed + */ public void cleanup(long olderThanNanos) { long now = System.nanoTime(); windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos); } + /** + * A single client's fixed window, tracking the window start time and the request count + * within it. + */ private static final class Window { + /** Start timestamp of the current window, in nanoseconds. */ final AtomicLong windowStart; + /** Number of requests counted in the current window. */ final AtomicLong count; + /** + * Creates a window starting at the given time with a zero count. + * + * @param now the window start timestamp in nanoseconds + */ Window(long now) { this.windowStart = new AtomicLong(now); this.count = new AtomicLong(0); } + /** + * Rolls the window over if it has expired, then counts this request and decides whether + * it stays within the limit. + * + * @param now the current time in nanoseconds + * @param limit the per-window request limit + * @param windowNanos the window length in nanoseconds + * @return an allow result with the remaining quota, or a deny result with the time until + * the window resets + */ Result tryAcquire(long now, long limit, long windowNanos) { long start = windowStart.get(); if (now - start >= windowNanos) { @@ -50,4 +102,4 @@ public final class FixedWindowLimiter implements RateLimiter { return Result.allow(limit - current, limit); } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java index fc7f7ee..5ca24cf 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java @@ -2,10 +2,33 @@ package dev.coph.nextusweb.server.ratelimit; import io.netty.handler.codec.http.HttpRequest; +/** + * Strategy for deriving the logical key under which a request is rate limited. The key + * determines which bucket a request counts against — for example one bucket per client IP, or + * one per authenticated user. + * + *Two ready-made resolvers are provided as factory methods: {@link #clientIp()} and + * {@link #userOrIp()}.
+ */ @FunctionalInterface public interface KeyResolver { + + /** + * Resolves the rate-limit key for a request. + * + * @param req the incoming HTTP request, used to inspect headers + * @param remoteAddress the transport-level remote address, used as a fallback + * @return the key the request should be counted against + */ String resolve(HttpRequest req, String remoteAddress); + /** + * Returns a resolver that keys on the client IP address. It prefers the first entry of the + * {@code X-Forwarded-For} header (so it works behind a reverse proxy) and falls back to the + * transport-level remote address when that header is absent. + * + * @return a client-IP key resolver + */ static KeyResolver clientIp() { return (req, remote) -> { String forwarded = req.headers().get("X-Forwarded-For"); @@ -17,6 +40,14 @@ public interface KeyResolver { }; } + /** + * Returns a resolver that keys on the authenticated user when possible, falling back to the + * client IP otherwise. A {@code Bearer} token from the {@code Authorization} header yields a + * {@code "u:Each key owns a bucket whose "water level" rises by one with every request and "leaks" + * back down at a fixed rate of {@code requestsPerSecond} units per second. A request is allowed + * while the (post-leak) level is below {@code capacity}; once full, requests are denied until + * enough has leaked away. Compared to the token bucket this smooths bursts into a steady + * outflow rather than allowing them through up front.
+ * + *State is held in {@link AtomicLong}s and updated with a lock-free compare-and-set loop, so + * the limiter is safe for concurrent use.
+ */ public final class LeakyBucketLimiter implements RateLimiter { + /** Maximum water level (number of queued units) the bucket tolerates. */ private final long capacity; - private final long leakIntervalNanos; + /** Nanoseconds it takes for exactly one unit to leak out. */ + private final long leakIntervalNanos; + /** Per-key buckets, created on demand. */ private final ConcurrentHashMapLazily creates the bucket for {@code key} and attempts to add one unit of water.
+ */ @Override public Result tryAcquire(String key, long nowNanos) { LeakyBucket b = buckets.computeIfAbsent(key, k -> new LeakyBucket(nowNanos)); return b.tryAcquire(nowNanos, capacity, leakIntervalNanos); } + /** + * Evicts buckets that have not leaked/been accessed within the given age. + * + * @param olderThanNanos maximum idle age in nanoseconds before a bucket is removed + */ public void cleanup(long olderThanNanos) { long now = System.nanoTime(); buckets.entrySet().removeIf(e -> now - e.getValue().lastLeakNanos.get() > olderThanNanos); } + /** + * A single client's leaky bucket, tracking the current water level and the timestamp up to + * which leakage has been accounted for. + */ private static final class LeakyBucket { + /** Current water level (number of units in the bucket). */ final AtomicLong waterLevel; + /** Timestamp, in nanoseconds, up to which leakage has been applied. */ final AtomicLong lastLeakNanos; + /** + * Creates an empty bucket. + * + * @param now the creation timestamp in nanoseconds + */ LeakyBucket(long now) { this.waterLevel = new AtomicLong(0); this.lastLeakNanos = new AtomicLong(now); } + /** + * Applies elapsed leakage and, if there is room, adds one unit of water. + * + * @param now the current time in nanoseconds + * @param capacity the bucket capacity + * @param leakIntervalNanos the nanoseconds per leaked unit + * @return an allow result with the remaining headroom, or a deny result with a retry + * hint when the bucket is full + */ Result tryAcquire(long now, long capacity, long leakIntervalNanos) { while (true) { long lastLeak = lastLeakNanos.get(); @@ -56,4 +107,4 @@ public final class LeakyBucketLimiter implements RateLimiter { } } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java index db7f795..cc35177 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java @@ -5,11 +5,35 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +/** + * Immutable mapping from request paths to the {@link Rule rate-limit rules} that apply to them. + * + *Three kinds of rules can be configured, resolved with the following precedence by + * {@link #rulesFor(String)}:
+ *A request is subject to the global rule (if any) plus the single most specific path rule + * that matches. Instances are built through the nested {@link Builder}.
+ */ public final class RateLimitConfig { + /** Rule applied to every request, or {@code null} if no global rule is configured. */ private final Rule globalRule; + /** Rules matched by exact path equality, keyed by path. */ private final MapThe list contains the global rule first (if configured) followed by at most one + * path-specific rule: the exact-path rule if one matches, otherwise the longest matching + * prefix rule. The returned list may be empty if no rule applies.
+ * + * @param path the request path + * @return the applicable rules, in evaluation order + */ public ListFor each request the gate evaluates every {@link RateLimitConfig.Rule rule} that applies + * to the request path. If any rule denies the request, evaluation stops and that denial is + * returned; otherwise the strictest (lowest remaining) allowance is returned so the headers + * reflect the tightest applicable budget.
+ * + *A daemon background thread periodically triggers cleanup of stale limiter state. The gate + * should be {@link #shutdown() shut down} when the server stops.
+ */ public final class RateLimitGate { + /** The rule set this gate enforces. */ private final RateLimitConfig config; + /** Single-threaded scheduler driving periodic cleanup of stale buckets. */ private final ScheduledExecutorService cleanup; + /** + * Creates a gate for the given configuration and starts a background cleanup task that runs + * every five minutes on a daemon thread. + * + * @param config the rate-limit rules to enforce + */ public RateLimitGate(RateLimitConfig config) { this.config = config; this.cleanup = Executors.newSingleThreadScheduledExecutor(r -> { @@ -22,8 +42,21 @@ public final class RateLimitGate { }); cleanup.scheduleAtFixedRate(this::doCleanup, 5, 5, TimeUnit.MINUTES); } - + + /** + * Evaluates all rules applicable to the given path and decides whether the request may + * proceed. + * + *Each rule's key is namespaced with the rule name to keep buckets from different rules + * independent. The first denial short-circuits and is returned immediately; if every rule + * allows the request, the result with the least remaining quota is returned.
+ * + * @param req the incoming request, used by key resolvers + * @param path the request path used to select rules + * @param remoteAddress the client's remote address, used as a key-resolver fallback + * @return the limiting result, or {@code null} if no rule applies to the path + */ public RateLimiter.Result check(HttpRequest req, String path, String remoteAddress) { ListDoes nothing when {@code result} is {@code null} (no rule applied). The retry hint is + * rounded up to whole seconds as required by the {@code Retry-After} header.
+ * + * @param result the limiting result, may be {@code null} + * @param res the response to decorate + */ public static void applyHeaders(RateLimiter.Result result, Response res) { if (result == null) return; res.header("X-RateLimit-Limit", String.valueOf(result.limit())); @@ -53,9 +96,16 @@ public final class RateLimitGate { } } + /** + * Periodic cleanup hook invoked by the background scheduler to evict limiter state that has + * not been touched recently (older than roughly ten minutes). + */ private void doCleanup() { long threshold = 10L * 60 * 1_000_000_000L; } + /** + * Stops the background cleanup scheduler. Should be called when the server shuts down. + */ public void shutdown() { cleanup.shutdown(); } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java index cb4affa..246ae93 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java @@ -1,21 +1,61 @@ package dev.coph.nextusweb.server.ratelimit; +/** + * Strategy interface for rate limiting. An implementation decides, per logical key, whether a + * single request may proceed right now. + * + *Concrete strategies in this package include {@link TokenBucketLimiter}, + * {@link LeakyBucketLimiter}, {@link FixedWindowLimiter} and {@link SlidingWindowLimiter}. + * Implementations are expected to be thread-safe, since the same limiter is shared across all + * request-handling threads.
+ */ public interface RateLimiter { + /** + * Attempts to consume one unit of quota for the given key at the given timestamp. + * + * @param key the logical bucket key (for example a client IP or user identifier) + * @param nowNanos the current time in nanoseconds, typically {@link System#nanoTime()} + * @return a {@link Result} describing whether the request was allowed and the remaining + * quota + */ Result tryAcquire(String key, long nowNanos); + /** + * Immutable outcome of a {@link #tryAcquire(String, long)} call. + * + * @param allowed whether the request may proceed + * @param remaining the remaining quota in the current window/bucket + * @param limit the configured limit, surfaced as {@code X-RateLimit-Limit} + * @param retryAfterMillis when denied, how long the caller should wait before retrying, in + * milliseconds (0 when allowed) + */ record Result( boolean allowed, long remaining, long limit, long retryAfterMillis ) { + /** + * Creates a result representing an allowed request. + * + * @param remaining the remaining quota after this request + * @param limit the configured limit + * @return an "allowed" result with no retry delay + */ public static Result allow(long remaining, long limit) { return new Result(true, remaining, limit, 0); } + /** + * Creates a result representing a denied (rate-limited) request. + * + * @param limit the configured limit + * @param retryAfterMillis how long to wait before retrying, in milliseconds + * @return a "denied" result with zero remaining quota + */ public static Result deny(long limit, long retryAfterMillis) { return new Result(false, 0, limit, retryAfterMillis); } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java index d750bd3..c13ed14 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java @@ -3,39 +3,99 @@ package dev.coph.nextusweb.server.ratelimit; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +/** + * A {@link RateLimiter} implementing the sliding window counter algorithm. + * + *This refines {@link FixedWindowLimiter} by smoothing the boundary between adjacent + * windows. It keeps the count for the current window and the previous window, and estimates the + * effective rate by weighting the previous window's count by how much of the current window has + * not yet elapsed. This avoids the burst-doubling that a plain fixed window allows at window + * boundaries, at the cost of a little extra state.
+ * + *Because the weighted calculation must read and update several fields atomically together, + * the per-key update is guarded by {@code synchronized}; the per-key state objects are stored + * in a {@link ConcurrentHashMap}.
+ */ public final class SlidingWindowLimiter implements RateLimiter { + /** Maximum effective (weighted) number of requests per window. */ private final long limit; + /** Window length in nanoseconds. */ private final long windowNanos; + /** Per-key sliding windows, created on demand. */ private final ConcurrentHashMapLazily creates the sliding window for {@code key} and counts this request against + * it.
+ */ @Override public Result tryAcquire(String key, long nowNanos) { SlidingWindow w = windows.computeIfAbsent(key, k -> new SlidingWindow(nowNanos)); return w.tryAcquire(nowNanos, limit, windowNanos); } + /** + * Evicts windows whose start time is older than the given age. + * + * @param olderThanNanos maximum age in nanoseconds before a window is removed + */ public void cleanup(long olderThanNanos) { long now = System.nanoTime(); windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos); } + /** + * A single client's sliding window, tracking the current window start plus the current and + * previous window counts. + */ private static final class SlidingWindow { + /** Start timestamp of the current window, in nanoseconds. */ final AtomicLong windowStart; + /** Request count accumulated in the current window. */ final AtomicLong currentCount; + /** Request count carried over from the immediately preceding window. */ final AtomicLong previousCount; + /** + * Creates a sliding window starting at the given time with zero counts. + * + * @param now the window start timestamp in nanoseconds + */ SlidingWindow(long now) { this.windowStart = new AtomicLong(now); this.currentCount = new AtomicLong(0); this.previousCount = new AtomicLong(0); } + /** + * Advances the window(s) as time has passed, computes the weighted request count and + * decides whether this request stays within the limit. + * + *If two or more full windows have elapsed the counters are reset; if exactly one has + * elapsed the current count becomes the previous count and a fresh window starts. The + * weighted count blends the previous window's count (scaled by the fraction of the + * current window still remaining) with the current count.
+ * + * @param now the current time in nanoseconds + * @param limit the per-window effective limit + * @param windowNanos the window length in nanoseconds + * @return an allow result with the remaining quota, or a deny result with the time until + * the window slides far enough to admit the request + */ synchronized Result tryAcquire(long now, long limit, long windowNanos) { long start = windowStart.get(); long elapsed = now - start; @@ -64,4 +124,4 @@ public final class SlidingWindowLimiter implements RateLimiter { return Result.allow(limit - weightedCount - 1, limit); } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java index e86b8c8..8bce1aa 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java @@ -3,59 +3,122 @@ package dev.coph.nextusweb.server.ratelimit; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +/** + * A {@link RateLimiter} implementing the token bucket algorithm. + * + *Each key owns a bucket that holds up to {@code burstCapacity} tokens and refills + * continuously at {@code requestsPerSecond} tokens per second. Every request consumes one + * token; if at least one token is available the request is allowed, otherwise it is denied + * with a retry hint computed from the refill rate. This permits short bursts (up to the bucket + * capacity) while bounding the sustained rate.
+ * + *Token counts are stored in fixed-point form (scaled by 1e9) inside {@link AtomicLong}s and + * updated with a lock-free compare-and-set loop, so the limiter is safe for concurrent use.
+ */ public final class TokenBucketLimiter implements RateLimiter { - private final long capacity; - private final double tokensPerNano; + /** Maximum number of tokens a bucket can hold (the burst allowance). */ + private final long capacity; + /** Refill rate expressed as tokens added per nanosecond. */ + private final double tokensPerNano; + /** Approximate nanoseconds between single-token refills, used for retry hints. */ private final long refillIntervalNs; + /** Per-key buckets, created on demand. */ private final ConcurrentHashMapLazily creates the bucket for {@code key} (initially full) and attempts to consume one + * token from it.
+ */ @Override public Result tryAcquire(String key, long nowNanos) { Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(capacity, nowNanos)); return b.tryAcquire(nowNanos, capacity, tokensPerNano, refillIntervalNs); } + /** + * Evicts buckets that have not been accessed within the given age, bounding memory use. + * + * @param olderThanNanos maximum idle age in nanoseconds before a bucket is removed + */ public void cleanup(long olderThanNanos) { long now = System.nanoTime(); buckets.entrySet().removeIf(e -> now - e.getValue().lastAccess() > olderThanNanos); } + /** + * A single client's token bucket. Tokens are stored in fixed-point form (multiplied by + * 1e9) to retain sub-token precision while using integer atomics. + * + * @param tokensFixed current token count in fixed-point (tokens × 1e9) + * @param lastRefillNanos timestamp of the last refill/consume, in nanoseconds + */ private record Bucket(AtomicLong tokensFixed, AtomicLong lastRefillNanos) { + /** + * Creates a full bucket. + * + * @param tokensFixed initial token count (in whole tokens, scaled internally) + * @param lastRefillNanos the creation timestamp in nanoseconds + */ private Bucket(long tokensFixed, long lastRefillNanos) { this(new AtomicLong(tokensFixed * 1_000_000_000L), new AtomicLong(lastRefillNanos)); } - + + /** + * Returns the timestamp of the last access, used by {@link #cleanup(long)}. + * + * @return the last-refill timestamp in nanoseconds + */ long lastAccess() { return lastRefillNanos.get(); } - + + /** + * Refills the bucket according to elapsed time and attempts to consume one token, + * retrying via compare-and-set on contention. + * + * @param now the current time in nanoseconds + * @param capacity the bucket capacity in whole tokens + * @param tokensPerNano the refill rate in tokens per nanosecond + * @param refillIntervalNs the nominal nanoseconds per token (unused in the hot path + * but kept for symmetry/retry computation) + * @return an allow result with the remaining tokens, or a deny result with a retry + * hint when fewer than one token is available + */ Result tryAcquire(long now, long capacity, double tokensPerNano, long refillIntervalNs) { while (true) { long lastRefill = lastRefillNanos.get(); long currentTokens = tokensFixed.get(); - + long elapsed = now - lastRefill; long refilled = currentTokens; if (elapsed > 0) { long addedFixed = (long) (elapsed * tokensPerNano * 1_000_000_000.0); refilled = Math.min(currentTokens + addedFixed, capacity * 1_000_000_000L); } - + long oneTokenFixed = 1_000_000_000L; if (refilled < oneTokenFixed) { long deficitFixed = oneTokenFixed - refilled; long retryNs = (long) (deficitFixed / (tokensPerNano * 1_000_000_000.0)); return Result.deny(capacity, Math.max(1, retryNs / 1_000_000)); } - + long newTokens = refilled - oneTokenFixed; if (tokensFixed.compareAndSet(currentTokens, newTokens)) { lastRefillNanos.set(now); @@ -64,4 +127,4 @@ public final class TokenBucketLimiter implements RateLimiter { } } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/router/Request.java b/src/main/java/dev/coph/nextusweb/server/router/Request.java index 8939c7c..7f8fe10 100644 --- a/src/main/java/dev/coph/nextusweb/server/router/Request.java +++ b/src/main/java/dev/coph/nextusweb/server/router/Request.java @@ -9,22 +9,57 @@ import tools.jackson.databind.JsonNode; import java.util.*; +/** + * A convenience wrapper around a Netty {@link FullHttpRequest} that exposes the parts of an + * HTTP request handlers typically need: path parameters, query parameters, headers and the + * request body (raw, as a parsed JSON tree, or deserialized into a type). + * + *Query parameters and the parsed JSON body are computed lazily and cached, so repeated + * accessors do not re-parse the request. A single {@code Request} instance is not intended to + * be shared across threads.
+ */ public final class Request { - - private final FullHttpRequest raw; - private final MapUnlike {@link #json()}, the result is not cached and the body is read fresh on each + * call.
+ * + * @param type the target type to deserialize into + * @paramThe status defaults to {@code 200 OK} and the body to an empty byte array. The body + * setters ({@link #text(String)}, {@link #json(String)}, {@link #json(Object)}) also set an + * appropriate {@code Content-Type} header.
+ */ public final class Response { - + + /** HTTP status code; defaults to {@code 200}. */ private int status = 200; + + /** Response headers accumulated by the handler. */ private final HttpHeaders headers = new DefaultHttpHeaders(); + + /** Response body bytes; defaults to an empty array. */ private byte[] body = new byte[0]; + /** + * Sets the HTTP status code. + * + * @param s the status code + * @return this response, for fluent chaining + */ public Response status(int s) { this.status = s; return this; } + /** + * Sets a response header, replacing any existing value for the same name. + * + * @param name the header name + * @param value the header value + * @return this response, for fluent chaining + */ public Response header(String name, String value) { headers.set(name, value); return this; } + /** + * Sets the body to the given text encoded as UTF-8 and sets the {@code Content-Type} to + * {@code text/plain; charset=utf-8}. + * + * @param s the text body + * @return this response, for fluent chaining + */ public Response text(String s) { this.body = s.getBytes(CharsetUtil.UTF_8); headers.set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8"); return this; } + /** + * Sets the body to an already-serialized JSON string and sets the {@code Content-Type} to + * {@code application/json; charset=utf-8}. + * + * @param json the raw JSON string + * @return this response, for fluent chaining + */ public Response json(String json) { this.body = json.getBytes(CharsetUtil.UTF_8); headers.set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8"); return this; } + /** + * Serializes the given value to JSON, sets it as the body and sets the {@code Content-Type} + * to {@code application/json; charset=utf-8}. + * + * @param value the object to serialize + * @return this response, for fluent chaining + * @throws RuntimeException if JSON serialization fails + */ public Response json(Object value) { try { this.body = JsonMapper.MAPPER.writeValueAsBytes(value); @@ -40,7 +89,24 @@ public final class Response { return this; } + /** + * Returns the configured HTTP status code. + * + * @return the status code + */ public int status() { return status; } + + /** + * Returns the accumulated response headers. + * + * @return the headers + */ public HttpHeaders headers() { return headers; } + + /** + * Returns the response body bytes. + * + * @return the body + */ public byte[] body() { return body; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/router/Router.java b/src/main/java/dev/coph/nextusweb/server/router/Router.java index 8467c8f..642054d 100644 --- a/src/main/java/dev/coph/nextusweb/server/router/Router.java +++ b/src/main/java/dev/coph/nextusweb/server/router/Router.java @@ -6,20 +6,65 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; +/** + * A trie-based HTTP router that maps {@code (method, path)} pairs to {@link Handler handlers}. + * + *Routes are stored in a prefix tree (radix-style {@link Node} tree) keyed by path segment. + * Three kinds of segments are supported:
+ *The router also holds an ordered list of {@link BiConsumer middlewares} that the request + * pipeline runs against every matched request before the handler executes.
+ * + *Registration mutates the shared trie and is intended to happen during start-up; + * {@link #resolve(HttpMethod, String)} is safe to call concurrently afterwards because the + * per-node maps are {@link ConcurrentHashMap}s.
+ */ public final class Router { + /** Root of the routing trie; every registered path descends from here. */ private final Node root = new Node(); + + /** Middlewares executed in insertion order for every matched request. */ private final ListStatic segments are matched first, falling back to a path-parameter child (capturing + * the segment value) and then a wildcard child. If the path cannot be matched a + * {@link Resolution.NotFound} is returned. If the path matches but no handler exists for + * the requested method, a {@link Resolution.MethodNotAllowed} carrying the set of allowed + * methods is returned. Otherwise a {@link Resolution.Match} with the handler and captured + * path parameters is returned.
+ * + * @param method the request's HTTP method + * @param path the request's path + * @return the resolution outcome, never {@code null} + */ public Resolution resolve(HttpMethod method, String path) { MapIt is thrown, for example, when a request body cannot be parsed as JSON or deserialized + * into the expected type. The request pipeline catches it and translates the + * {@linkplain #getMessage() message} into a {@code 400} response, distinguishing it from + * unexpected errors which produce a {@code 500}.
+ */ public final class BadRequestException extends RuntimeException { + + /** + * Creates a bad-request exception with a human-readable explanation. + * + * @param message the detail message describing why the request is invalid + */ public BadRequestException(String message) { super(message); } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketConfig.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketConfig.java index 16c7710..d24f39a 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketConfig.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketConfig.java @@ -5,17 +5,38 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; +/** + * Immutable configuration for the WebSocket subsystem: frame and message size limits, idle + * timeout, allowed origins, negotiated subprotocols, and compression. Instances are created + * through the nested {@link Builder}. + * + *The values configured here govern how {@code HttpRequestHandler} sets up the WebSocket + * portion of the Netty pipeline during the upgrade handshake.
+ */ public final class WebSocketConfig { + /** Maximum size, in bytes, of a single WebSocket frame payload. */ private final int maxFramePayloadLength; + /** Maximum size, in bytes, of an aggregated (multi-frame) message. */ private final int maxAggregatedMessageSize; + /** Idle timeout after which an inactive connection is closed; {@code null} disables it. */ private final Duration idleTimeout; + /** Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}. */ private final SetIt creates a {@link WebSocketSession} when the handshake completes, then translates each + * incoming frame into the matching callback ({@code onMessage}, {@code onBinary}, + * {@code onClose}). Callbacks are dispatched on a virtual-thread executor so application code + * may block without stalling the Netty event loop, and any exception they throw is funneled to + * {@link WebSocketHandler#onError}. Idle-timeout events close the channel.
+ * + *This class is package-private; instances are created via + * {@link WebSocketFrameHandlerFactory}.
+ */ final class WebSocketFrameHandler extends SimpleChannelInboundHandlerIt is backed by a Netty {@link ChannelGroup}, which automatically removes channels as they + * close, so callers do not need to prune disconnected sessions manually. The group is + * thread-safe.
+ */ public final class WebSocketGroup { + /** Underlying Netty channel group holding the member connections. */ private final ChannelGroup channels; + /** Human-readable name of this group. */ private final String name; + /** + * Creates an unnamed group (named {@code "anonymous"}). + */ public WebSocketGroup() { this("anonymous"); } + /** + * Creates a named group. + * + * @param name the group name + */ public WebSocketGroup(String name) { this.name = name; this.channels = new DefaultChannelGroup(name, GlobalEventExecutor.INSTANCE); } + /** + * @return the group name + */ public String name() { return name; } + /** + * Adds a session to the group. + * + * @param session the session to add + * @return this group, for fluent chaining + */ public WebSocketGroup add(WebSocketSession session) { channels.add(session.channel()); return this; } + /** + * Removes a session from the group. + * + * @param session the session to remove + * @return this group, for fluent chaining + */ public WebSocketGroup remove(WebSocketSession session) { channels.remove(session.channel()); return this; } + /** + * @return the current number of member connections + */ public int size() { return channels.size(); } + /** + * Broadcasts a text message to every member of the group. + * + * @param text the text to send + * @return this group, for fluent chaining + */ public WebSocketGroup broadcast(String text) { channels.writeAndFlush(new TextWebSocketFrame(text)); return this; } + /** + * Serializes the given value to JSON and broadcasts it as a text message to every member. + * + * @param value the object to serialize and broadcast + * @return this group, for fluent chaining + * @throws RuntimeException if JSON serialization fails + */ public WebSocketGroup broadcastJson(Object value) { try { byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(value); @@ -56,6 +106,12 @@ public final class WebSocketGroup { return this; } + /** + * Broadcasts a binary message to every active member, allocating a fresh buffer per channel. + * + * @param data the bytes to broadcast + * @return this group, for fluent chaining + */ public WebSocketGroup broadcastBinary(byte[] data) { for (var ch : channels) { if (ch.isActive()) { @@ -66,6 +122,14 @@ public final class WebSocketGroup { return this; } + /** + * Broadcasts a text message to every active member except one — typically the sender, + * so a client does not receive its own message echoed back. + * + * @param exclude the session to skip, or {@code null} to broadcast to everyone + * @param text the text to send + * @return this group, for fluent chaining + */ public WebSocketGroup broadcastExcept(WebSocketSession exclude, String text) { var excludeCh = exclude == null ? null : exclude.channel(); for (var ch : channels) { @@ -76,6 +140,11 @@ public final class WebSocketGroup { return this; } + /** + * Closes every connection in the group. + * + * @return this group, for fluent chaining + */ public WebSocketGroup closeAll() { channels.close(); return this; diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketHandler.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketHandler.java index 7cc3453..9631704 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketHandler.java @@ -1,19 +1,67 @@ package dev.coph.nextusweb.server.websocket; +/** + * Application-facing callback interface for a WebSocket endpoint. Implementations react to the + * lifecycle events of a single connection: opening, incoming text and binary messages, closing, + * and errors. + * + *Every method has an empty default implementation, so handlers need only override the + * events they care about. Callbacks are dispatched on virtual threads by the framework, so they + * may perform blocking work, and they are allowed to throw — any thrown exception is + * routed to {@link #onError(WebSocketSession, Throwable)}.
+ * + * @see WebSocketSession + * @see WebSocketRouter + */ public interface WebSocketHandler { + /** + * Invoked once the WebSocket handshake has completed and the session is ready for use. + * + * @param session the newly opened session + * @throws Exception if the handler fails; routed to {@link #onError} + */ default void onOpen(WebSocketSession session) throws Exception { } + /** + * Invoked when a text message is received. + * + * @param session the session the message arrived on + * @param message the decoded text payload + * @throws Exception if the handler fails; routed to {@link #onError} + */ default void onMessage(WebSocketSession session, String message) throws Exception { } + /** + * Invoked when a binary message is received. + * + * @param session the session the message arrived on + * @param data the raw binary payload + * @throws Exception if the handler fails; routed to {@link #onError} + */ default void onBinary(WebSocketSession session, byte[] data) throws Exception { } + /** + * Invoked when the connection closes, whether initiated by the peer or the server. + * + * @param session the session being closed + * @param code the WebSocket close status code + * @param reason the close reason text (empty if none was provided) + * @throws Exception if the handler fails; routed to {@link #onError} + */ default void onClose(WebSocketSession session, int code, String reason) throws Exception { } + /** + * Invoked when an error occurs on the connection or when another callback throws. + * + * @param session the affected session + * @param cause the error that occurred + * @throws Exception if the error handler itself fails (such failures are swallowed) + */ default void onError(WebSocketSession session, Throwable cause) throws Exception { } } diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketRouter.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketRouter.java index 9dbe12e..9692f62 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketRouter.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketRouter.java @@ -6,10 +6,27 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +/** + * A trie-based router that maps WebSocket upgrade paths to {@link WebSocketHandler}s. + * + *It mirrors the HTTP {@link dev.coph.nextusweb.server.router.Router Router} but is simpler: + * a path resolves to a single handler (there is no HTTP method dimension) and only static and + * {@code {param}} path-parameter segments are supported (no wildcards). Registration mutates the + * shared trie at start-up; {@link #resolve(String)} is safe to call concurrently afterwards.
+ */ public final class WebSocketRouter { + /** Root of the routing trie. */ private final Node root = new Node(); + /** + * Registers a handler at the given path, creating any missing trie nodes. Segments wrapped + * in braces (e.g. {@code /chat/{room}}) are treated as path parameters. + * + * @param path the WebSocket path to mount the handler at + * @param handler the handler to invoke for connections on that path + * @return this router, for fluent chaining + */ public WebSocketRouter on(String path, WebSocketHandler handler) { Node node = root; for (String segment : split(path)) { @@ -27,6 +44,13 @@ public final class WebSocketRouter { return this; } + /** + * Resolves a path to its handler, capturing any path parameters along the way. + * + * @param path the request path + * @return a {@link Resolution} carrying the handler and captured parameters, or {@code null} + * if no handler is registered for the path + */ public Resolution resolve(String path) { MapIt wraps the underlying Netty {@link Channel} and offers convenient methods to send text, + * JSON and binary payloads, to ping the peer, and to close the connection. It also carries + * read-only connection metadata (a generated id, the path, and captured path parameters) and a + * thread-safe bag of arbitrary {@link #attribute(String, Object) attributes} that handlers can + * use to associate state with the connection.
+ * + *Each connection's session is stored on its channel under {@link #SESSION_KEY} so the frame + * handler can retrieve it for every incoming frame.
+ */ public final class WebSocketSession { + /** Channel attribute key under which the session is stored on its Netty channel. */ static final AttributeKey