From 5d6e8622bf058424feb0f1d413501f168a6e4eda Mon Sep 17 00:00:00 2001 From: CodingPhoenixx Date: Fri, 29 May 2026 08:50:05 +0200 Subject: [PATCH] Add comprehensive Javadoc documentation to server components, including annotations, request/response handling, routing, and WebSocket support. --- .../nextusweb/server/HttpRequestHandler.java | 91 +++++++++++- .../dev/coph/nextusweb/server/HttpServer.java | 87 +++++++++++ .../server/annotation/AnnotationScanner.java | 90 +++++++++++- .../nextusweb/server/annotation/CUSTOM.java | 32 +++- .../server/annotation/Controller.java | 30 +++- .../nextusweb/server/annotation/DELETE.java | 23 ++- .../coph/nextusweb/server/annotation/GET.java | 23 ++- .../nextusweb/server/annotation/PATCH.java | 23 ++- .../nextusweb/server/annotation/POST.java | 23 ++- .../coph/nextusweb/server/annotation/PUT.java | 23 ++- .../nextusweb/server/annotation/Route.java | 33 ++++- .../nextusweb/server/cores/CorsConfig.java | 125 +++++++++++++++- .../nextusweb/server/cores/CorsHandler.java | 68 ++++++++- .../nextusweb/server/json/JsonMapper.java | 22 ++- .../server/ratelimit/FixedWindowLimiter.java | 54 ++++++- .../server/ratelimit/KeyResolver.java | 33 ++++- .../server/ratelimit/LeakyBucketLimiter.java | 55 ++++++- .../server/ratelimit/RateLimitConfig.java | 90 +++++++++++- .../server/ratelimit/RateLimitGate.java | 56 ++++++- .../server/ratelimit/RateLimiter.java | 42 +++++- .../ratelimit/SlidingWindowLimiter.java | 62 +++++++- .../server/ratelimit/TokenBucketLimiter.java | 79 +++++++++- .../coph/nextusweb/server/router/Request.java | 93 +++++++++++- .../nextusweb/server/router/Response.java | 70 ++++++++- .../coph/nextusweb/server/router/Router.java | 138 +++++++++++++++++- .../router/exception/BadRequestException.java | 17 ++- .../server/websocket/WebSocketConfig.java | 137 +++++++++++++++++ .../websocket/WebSocketFrameHandler.java | 66 ++++++++- .../WebSocketFrameHandlerFactory.java | 19 +++ .../server/websocket/WebSocketGroup.java | 69 +++++++++ .../server/websocket/WebSocketHandler.java | 48 ++++++ .../server/websocket/WebSocketRouter.java | 45 ++++++ .../server/websocket/WebSocketSession.java | 125 ++++++++++++++++ 33 files changed, 1938 insertions(+), 53 deletions(-) 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 SimpleChannelInboundHandler { + /** Executor running one virtual thread per task, used to offload blocking handler work. */ private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); + /** Router resolving requests to handlers. */ private final Router router; + /** CORS handler, or {@code null} if CORS is disabled. */ private final CorsHandler cors; + /** Rate-limit gate, or {@code null} if rate limiting is disabled. */ private final RateLimitGate rateLimit; + /** WebSocket router, or {@code null} if WebSocket support is disabled. */ private final WebSocketRouter wsRouter; + /** WebSocket configuration; only consulted when {@link #wsRouter} is non-null. */ private final WebSocketConfig wsConfig; + /** + * Creates a handler without WebSocket support. + * + * @param router the router resolving requests + * @param cors the CORS handler, or {@code null} to disable CORS + * @param rateLimit the rate-limit gate, or {@code null} to disable rate limiting + */ public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit) { this(router, cors, rateLimit, null, null); } + /** + * Creates a handler, optionally with WebSocket support. + * + * @param router the router resolving requests + * @param cors the CORS handler, or {@code null} to disable CORS + * @param rateLimit 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 + */ public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, WebSocketRouter wsRouter, WebSocketConfig wsConfig) { this.router = router; @@ -52,6 +86,14 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandlerResolves the path against the WebSocket router; if no handler matches the upgrade is + * declined. Otherwise the origin is validated, the WebSocket protocol/compression/idle + * handlers and the application frame handler are inserted into the pipeline, and the request + * is re-fired so Netty performs the handshake.

+ * + * @param ctx the channel context + * @param req the upgrade request + * @return {@code true} if the request was consumed (handshake started or rejected), + * {@code false} if no WebSocket route matched and normal HTTP handling should + * continue + */ private boolean handleWebSocketUpgrade(ChannelHandlerContext ctx, FullHttpRequest req) { String path = new QueryStringDecoder(req.uri()).path(); WebSocketRouter.Resolution resolution = wsRouter.resolve(path); @@ -125,6 +189,18 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandlerExceptions from the handler are mapped to responses: a {@link BadRequestException} + * becomes a {@code 400}, any other exception a {@code 500}. Routing misses become + * {@code 404}, and method mismatches a {@code 405} with an {@code Allow} header. CORS and + * rate-limit headers are applied to the final response in all cases.

+ * + * @param ctx the channel context + * @param raw the aggregated request being handled + */ private void handle(ChannelHandlerContext ctx, FullHttpRequest raw) { String origin = raw.headers().get("Origin"); @@ -182,6 +258,13 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandlerThe class doubles as a small fluent builder: {@link #builder(int, Router)} creates an + * instance bound to a port and {@link Router}, and the {@code withXxx} methods attach optional + * features (CORS, rate limiting, WebSockets) before {@link #start()} launches the server.

+ * + *

At 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:

+ *
    + *
  1. validates that the method has the required {@code (Request, Response)} signature and + * a {@code void} return type;
  2. + *
  3. creates a {@link MethodHandle} bound to the controller instance for fast, + * reflection-free invocation;
  4. + *
  5. registers a {@link Router.Handler} that delegates to that handle under the resolved + * HTTP method and full path.
  6. + *
+ * + *

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 Set allowedOrigins; + /** HTTP methods advertised as allowed in preflight responses. */ private final Set allowedMethods; + /** Request headers advertised as allowed in preflight responses. */ private final Set allowedHeaders; + /** Response headers exposed to the browser via {@code Access-Control-Expose-Headers}. */ private final Set exposedHeaders; + /** Whether credentialed (cookie/authorization) requests are permitted. */ private final boolean allowCredentials; + /** How long (in seconds) a preflight response may be cached by the browser. */ private final long maxAgeSeconds; + /** Whether any origin is allowed (the {@code *} wildcard). */ private final boolean allowAnyOrigin; + /** + * Builds an immutable configuration from a {@link Builder}, defensively copying its + * collections. + * + * @param b the builder carrying the configured values + * @throws IllegalStateException if a wildcard origin is combined with + * {@code allowCredentials = true} + */ private CorsConfig(Builder b) { this.allowedOrigins = Set.copyOf(b.allowedOrigins); this.allowedMethods = Set.copyOf(b.allowedMethods); @@ -32,6 +58,14 @@ public final class CorsConfig { } } + /** + * Creates a permissive, development-friendly configuration that allows any origin, the + * common HTTP methods, a handful of common headers and a one-hour preflight cache. + * + *

Because 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 Set allowedMethods() { return allowedMethods; } + /** + * @return the immutable set of allowed request headers + */ public Set allowedHeaders() { return allowedHeaders; } + /** + * @return the immutable set of response headers exposed to the browser + */ public Set exposedHeaders() { return exposedHeaders; } + /** + * @return {@code true} if credentialed requests are permitted + */ public boolean allowCredentials() { return allowCredentials; } + /** + * @return the preflight cache lifetime in seconds ({@code 0} disables the header) + */ public long maxAgeSeconds() { return maxAgeSeconds; } + /** + * @return {@code true} if any origin is permitted + */ public boolean allowAnyOrigin() { return allowAnyOrigin; } + /** + * Fluent builder for {@link CorsConfig}. All collection setters are additive, so they may + * be called multiple times to accumulate values. + */ public static final class Builder { + /** Accumulated explicit origins. */ private final Set allowedOrigins = new HashSet<>(); + /** Accumulated allowed methods. */ private final Set allowedMethods = new HashSet<>(); + /** Accumulated allowed request headers. */ private final Set allowedHeaders = new HashSet<>(); + /** Accumulated exposed response headers. */ private final Set exposedHeaders = new HashSet<>(); + /** Whether credentialed requests are permitted; defaults to {@code false}. */ private boolean allowCredentials = false; + /** Preflight cache lifetime in seconds; defaults to {@code 0} (disabled). */ private long maxAgeSeconds = 0; + /** Whether any origin is permitted; defaults to {@code false}. */ private boolean allowAnyOrigin = false; + /** + * Adds one or more explicit origins to the allow-list. + * + * @param origins the origins to allow + * @return this builder, for fluent chaining + */ public Builder allowedOrigins(String... origins) { Collections.addAll(allowedOrigins, origins); return this; } + /** + * Allows requests from any origin (the {@code *} wildcard). Cannot be combined with + * {@link #allowCredentials(boolean) credentials}. + * + * @return this builder, for fluent chaining + */ public Builder anyOrigin() { this.allowAnyOrigin = true; return this; } + /** + * Adds one or more allowed HTTP methods. + * + * @param ms the methods to allow + * @return this builder, for fluent chaining + */ public Builder allowedMethods(HttpMethod... ms) { Collections.addAll(allowedMethods, ms); return this; } + /** + * Adds one or more allowed request headers. + * + * @param hs the request headers to allow + * @return this builder, for fluent chaining + */ public Builder allowedHeaders(String... hs) { Collections.addAll(allowedHeaders, hs); return this; } + /** + * Adds one or more response headers to expose to the browser. + * + * @param hs the response headers to expose + * @return this builder, for fluent chaining + */ public Builder exposedHeaders(String... hs) { Collections.addAll(exposedHeaders, hs); return this; } + /** + * Sets whether credentialed requests are permitted. + * + * @param v {@code true} to allow credentials + * @return this builder, for fluent chaining + */ public Builder allowCredentials(boolean v) { this.allowCredentials = v; return this; } + /** + * Sets the preflight cache lifetime in seconds. + * + * @param s the max-age in seconds ({@code 0} disables the header) + * @return this builder, for fluent chaining + */ public Builder maxAgeSeconds(long s) { this.maxAgeSeconds = s; return this; } + /** + * Builds the immutable {@link CorsConfig}. + * + * @return the configured instance + * @throws IllegalStateException if any origin is combined with credentials + */ public CorsConfig build() { return new CorsConfig(this); } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java b/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java index c6a45da..3fa7e1d 100644 --- a/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java @@ -5,13 +5,38 @@ import io.netty.handler.codec.http.*; import java.util.stream.Collectors; +/** + * Applies a {@link CorsConfig} to outgoing responses and handles CORS preflight requests. + * + *

The 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:

+ *
    + *
  • {@link #applyHeaders(String, Response)} decorates a normal response with the + * appropriate {@code Access-Control-*} headers;
  • + *
  • {@link #handlePreflight(String, HttpHeaders)} produces a complete response for an + * {@code OPTIONS} preflight request.
  • + *
+ * + * @see CorsConfig + */ public final class CorsHandler { + /** The policy this handler enforces. */ private final CorsConfig config; + /** Pre-joined {@code Access-Control-Allow-Methods} value. */ private final String allowedMethodsHeader; + /** Pre-joined {@code Access-Control-Allow-Headers} value. */ private final String allowedHeadersHeader; + /** Pre-joined {@code Access-Control-Expose-Headers} value. */ private final String exposedHeadersHeader; + /** + * Creates a handler for the given configuration, pre-computing the header strings it will + * emit. + * + * @param config the CORS policy to enforce + */ public CorsHandler(CorsConfig config) { this.config = config; this.allowedMethodsHeader = config.allowedMethods().stream().map(HttpMethod::name).collect(Collectors.joining(", ")); @@ -19,9 +44,21 @@ public final class CorsHandler { this.exposedHeadersHeader = String.join(", ", config.exposedHeaders()); } - + + /** + * Adds the {@code Access-Control-Allow-Origin} (and related) headers to a response, if and + * only if the request carried an allowed {@code Origin}. + * + *

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 ConcurrentHashMap windows = new ConcurrentHashMap<>(); + /** + * Creates a fixed-window limiter. + * + * @param limit the maximum number of requests per window + * @param windowMillis the window length in milliseconds + */ public FixedWindowLimiter(long limit, long windowMillis) { this.limit = limit; this.windowNanos = windowMillis * 1_000_000L; } + /** + * {@inheritDoc} + * + *

Lazily 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:"} key; otherwise the {@code "ip:
"} key from {@link #clientIp()} + * is used. + * + * @return a user-or-IP key resolver + */ static KeyResolver userOrIp() { return (req, remote) -> { String auth = req.headers().get("Authorization"); @@ -26,4 +57,4 @@ public interface KeyResolver { return "ip:" + clientIp().resolve(req, remote); }; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java index 176f4a7..a0cb2f0 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java @@ -3,37 +3,88 @@ package dev.coph.nextusweb.server.ratelimit; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +/** + * A {@link RateLimiter} implementing the leaky bucket algorithm. + * + *

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 ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + /** + * Creates a leaky-bucket limiter. + * + * @param requestsPerSecond the steady leak (drain) rate in units per second + * @param capacity the bucket capacity, i.e. the maximum tolerated backlog + */ public LeakyBucketLimiter(long requestsPerSecond, long capacity) { this.capacity = capacity; this.leakIntervalNanos = 1_000_000_000L / Math.max(1, requestsPerSecond); } + /** + * {@inheritDoc} + * + *

Lazily 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)}:

+ *
    + *
  1. an optional global rule that applies to every request;
  2. + *
  3. exact-path rules matched by exact path equality;
  4. + *
  5. prefix rules matched by path prefix, evaluated longest-prefix-first.
  6. + *
+ * + *

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 Map exactPathRules; + /** Prefix rules, pre-sorted longest-prefix-first so the most specific match wins. */ private final List prefixRules; + + /** + * Builds an immutable configuration from a {@link Builder}, copying the exact-path rules + * and sorting the prefix rules by descending prefix length. + * + * @param b the builder carrying the configured rules + */ private RateLimitConfig(Builder b) { this.globalRule = b.globalRule; this.exactPathRules = Map.copyOf(b.exactPathRules); @@ -18,10 +42,25 @@ public final class RateLimitConfig { .toList(); } + /** + * Creates a new, empty {@link Builder}. + * + * @return a fresh builder + */ public static Builder builder() { return new Builder(); } + /** + * Returns the ordered list of rules that apply to the given path. + * + *

The 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 List rulesFor(String path) { List rules = new ArrayList<>(2); if (globalRule != null) rules.add(globalRule); @@ -40,34 +79,83 @@ public final class RateLimitConfig { return rules; } + /** + * A single rate-limit rule: a limiter, the key resolver feeding it, and a name used to + * namespace keys and aid diagnostics. + * + * @param limiter the limiter that enforces the quota + * @param keyResolver resolves the per-request key the limiter buckets on + * @param name a human-readable label (e.g. {@code "global"} or a path/prefix) + */ public record Rule(RateLimiter limiter, KeyResolver keyResolver, String name) { } + /** + * Internal pairing of a path prefix with the rule that applies to paths starting with it. + * + * @param prefix the path prefix + * @param rule the rule to apply for matching paths + */ private record PrefixRule(String prefix, Rule rule) { } + /** + * Fluent builder for {@link RateLimitConfig}. + */ public static final class Builder { + /** Accumulated exact-path rules, keyed by path. */ private final Map exactPathRules = new HashMap<>(); + /** Accumulated prefix rules. */ private final List prefixRules = new ArrayList<>(); + /** The global rule, if configured. */ private Rule globalRule; + /** + * Sets the global rule applied to every request. + * + * @param limiter the limiter enforcing the global quota + * @param keys the key resolver for the global rule + * @return this builder, for fluent chaining + */ public Builder global(RateLimiter limiter, KeyResolver keys) { this.globalRule = new Rule(limiter, keys, "global"); return this; } + /** + * Adds a rule that applies only to requests whose path equals {@code path} exactly. + * + * @param path the exact request path + * @param limiter the limiter enforcing the quota + * @param keys the key resolver for this rule + * @return this builder, for fluent chaining + */ public Builder forPath(String path, RateLimiter limiter, KeyResolver keys) { exactPathRules.put(path, new Rule(limiter, keys, path)); return this; } + /** + * Adds a rule that applies to requests whose path starts with {@code prefix}. When + * several prefixes match, the longest one wins. + * + * @param prefix the path prefix + * @param limiter the limiter enforcing the quota + * @param keys the key resolver for this rule + * @return this builder, for fluent chaining + */ public Builder forPrefix(String prefix, RateLimiter limiter, KeyResolver keys) { prefixRules.add(new PrefixRule(prefix, new Rule(limiter, keys, prefix + "*"))); return this; } + /** + * Builds the immutable {@link RateLimitConfig}. + * + * @return the configured instance + */ public RateLimitConfig build() { return new RateLimitConfig(this); } } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java index 2cd7637..445d5b7 100644 --- a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java @@ -8,11 +8,31 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +/** + * Request-pipeline entry point that applies a {@link RateLimitConfig} to incoming requests and + * surfaces the outcome as standard {@code X-RateLimit-*} response headers. + * + *

For 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) { List rules = config.rulesFor(path); if (rules.isEmpty()) return null; @@ -35,7 +68,7 @@ public final class RateLimitGate { String key = rule.name() + ":" + rule.keyResolver().resolve(req, remoteAddress); RateLimiter.Result result = rule.limiter().tryAcquire(key, now); - if (!result.allowed()) return result; + if (!result.allowed()) return result; if (strictest == null || result.remaining() < strictest.remaining()) { strictest = result; @@ -44,6 +77,16 @@ public final class RateLimitGate { return strictest; } + /** + * Writes the standard rate-limit headers ({@code X-RateLimit-Limit}, + * {@code X-RateLimit-Remaining}, and {@code Retry-After} when denied) onto a response. + * + *

Does 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 ConcurrentHashMap windows = new ConcurrentHashMap<>(); + /** + * Creates a sliding-window limiter. + * + * @param limit the maximum effective number of requests per window + * @param windowMillis the window length in milliseconds + */ public SlidingWindowLimiter(long limit, long windowMillis) { this.limit = limit; this.windowNanos = windowMillis * 1_000_000L; } + /** + * {@inheritDoc} + * + *

Lazily 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 ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + /** + * Creates a token-bucket limiter. + * + * @param requestsPerSecond the sustained refill rate in tokens (requests) per second + * @param burstCapacity the maximum burst size, i.e. the bucket capacity in tokens + */ public TokenBucketLimiter(long requestsPerSecond, long burstCapacity) { this.capacity = burstCapacity; this.tokensPerNano = (double) requestsPerSecond / 1_000_000_000.0; this.refillIntervalNs = 1_000_000_000L / Math.max(1, requestsPerSecond); } + /** + * {@inheritDoc} + * + *

Lazily 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 Map pathParams; - private Map> queryParams; - private JsonNode jsonCache; + /** The underlying Netty request this wrapper delegates to. */ + private final FullHttpRequest raw; + + /** Path parameters captured by the router while matching, keyed by name. */ + private final Map pathParams; + + /** Lazily decoded query-string parameters; {@code null} until first accessed. */ + private Map> queryParams; + + /** Lazily parsed JSON body; {@code null} until {@link #json()} is first called. */ + private JsonNode jsonCache; + + /** + * Creates a request wrapper. + * + * @param raw the underlying Netty request + * @param pathParams the path parameters captured during routing, keyed by name + */ public Request(FullHttpRequest raw, Map pathParams) { this.raw = raw; this.pathParams = pathParams; } + /** + * Returns the value of a path parameter captured during routing. + * + * @param name the parameter name as declared in the route (without braces) + * @return the captured value, or {@code null} if no such parameter was matched + */ public String pathParam(String name) { return pathParams.get(name); } + /** + * Returns the first value of a query-string parameter, decoding the query string on first + * access. + * + * @param name the query parameter name + * @return the first value, or {@code null} if the parameter is absent or has no value + */ public String queryParam(String name) { if (queryParams == null) { queryParams = new QueryStringDecoder(raw.uri()).parameters(); @@ -33,6 +68,13 @@ public final class Request { return values == null || values.isEmpty() ? null : values.getFirst(); } + /** + * Returns all values of a query-string parameter, decoding the query string on first + * access. + * + * @param name the query parameter name + * @return the (possibly empty) list of values for the parameter; never {@code null} + */ public List queryParams(String name) { if (queryParams == null) { queryParams = new QueryStringDecoder(raw.uri()).parameters(); @@ -40,14 +82,32 @@ public final class Request { return queryParams.getOrDefault(name, List.of()); } + /** + * Returns the value of a request header. + * + * @param name the (case-insensitive) header name + * @return the header value, or {@code null} if not present + */ public String header(String name) { return raw.headers().get(name); } + /** + * Returns the request body decoded as a UTF-8 string. + * + * @return the body as text (empty if there is no body) + */ public String body() { return raw.content().toString(CharsetUtil.UTF_8); } + /** + * Parses the request body as a JSON tree, caching the result for subsequent calls. An + * empty body resolves to a JSON {@code null} node rather than an error. + * + * @return the parsed JSON tree + * @throws BadRequestException if the body is not valid JSON + */ public JsonNode json() { if (jsonCache == null) { try { @@ -65,6 +125,17 @@ public final class Request { return jsonCache; } + /** + * Deserializes the request body directly into an instance of the given type. + * + *

Unlike {@link #json()}, the result is not cached and the body is read fresh on each + * call.

+ * + * @param type the target type to deserialize into + * @param the target type + * @return the deserialized value + * @throws BadRequestException if the body cannot be deserialized into {@code type} + */ public T jsonAs(Class type) { try { byte[] bytes = new byte[raw.content().readableBytes()]; @@ -76,11 +147,21 @@ public final class Request { } } + /** + * Returns the request's HTTP method. + * + * @return the HTTP method + */ public HttpMethod method() { return raw.method(); } + /** + * Returns the request's path, with any query string stripped off. + * + * @return the decoded request path + */ public String path() { return new QueryStringDecoder(raw.uri()).path(); } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/router/Response.java b/src/main/java/dev/coph/nextusweb/server/router/Response.java index 93136cf..2c77809 100644 --- a/src/main/java/dev/coph/nextusweb/server/router/Response.java +++ b/src/main/java/dev/coph/nextusweb/server/router/Response.java @@ -5,31 +5,80 @@ import io.netty.handler.codec.http.*; import io.netty.util.CharsetUtil; import tools.jackson.core.JacksonException; +/** + * A mutable builder for the HTTP response a handler produces. Handlers set the status code, + * headers and body fluently; the request pipeline later reads these back via the accessor + * methods to construct the actual Netty response on the wire. + * + *

The 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:

+ *
    + *
  • static segments such as {@code users}, matched literally;
  • + *
  • path parameters written as {@code {name}}, which match any single + * segment and capture its value under {@code name};
  • + *
  • wildcards written as {@code *}, which match any single segment + * without capturing it.
  • + *
+ * + *

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 List> middlewares = new ArrayList<>(); + /** + * Registers a middleware that runs against every matched request before its handler. + * + * @param middleware a callback receiving the request and the response being built + * @return this router, for fluent chaining + */ public Router use(BiConsumer middleware) { middlewares.add(middleware); return this; } + /** + * Registers a handler for the {@code GET} method at the given path. + * + * @param path the route path (supports {@code {param}} and {@code *} segments) + * @param h the handler to invoke + * @return this router, for fluent chaining + */ public Router get(String path, Handler h) { return register(HttpMethod.GET, path, h); } + /** + * Registers a handler for an arbitrary HTTP method at the given path, creating any missing + * trie nodes along the way. + * + * @param method the HTTP method to bind the handler to + * @param path the route path (supports {@code {param}} and {@code *} segments) + * @param h the handler to invoke + * @return this router, for fluent chaining + */ public Router register(HttpMethod method, String path, Handler h) { Node node = root; for (String segment : split(path)) { @@ -40,6 +85,13 @@ public final class Router { return this; } + /** + * Splits a path into its non-empty segments, ignoring leading and collapsing internal + * slashes. For example {@code "/a/b/"} yields {@code ["a", "b"]}. + * + * @param path the raw path + * @return the ordered list of path segments + */ private static List split(String path) { List out = new ArrayList<>(); int start = path.startsWith("/") ? 1 : 0; @@ -53,18 +105,53 @@ public final class Router { return out; } + /** + * Registers a handler for the {@code POST} method at the given path. + * + * @param path the route path + * @param h the handler to invoke + * @return this router, for fluent chaining + */ public Router post(String path, Handler h) { return register(HttpMethod.POST, path, h); } + /** + * Registers a handler for the {@code PUT} method at the given path. + * + * @param path the route path + * @param h the handler to invoke + * @return this router, for fluent chaining + */ public Router put(String path, Handler h) { return register(HttpMethod.PUT, path, h); } + /** + * Registers a handler for the {@code DELETE} method at the given path. + * + * @param path the route path + * @param h the handler to invoke + * @return this router, for fluent chaining + */ public Router delete(String path, Handler h) { return register(HttpMethod.DELETE, path, h); } - + + /** + * Resolves an incoming request against the routing trie. + * + *

Static 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) { Map params = new HashMap<>(4); Node node = root; @@ -95,31 +182,74 @@ public final class Router { return new Resolution.NotFound(); } + /** + * Returns the live, ordered list of registered middlewares. + * + * @return the middleware list (modifications affect this router) + */ public List> middlewares() { return middlewares; } + /** + * Sealed result type describing the three possible outcomes of {@link #resolve}. + */ public sealed interface Resolution { + /** + * A successful match. + * + * @param handler the handler to invoke for the request + * @param pathParams the path parameters captured while matching, keyed by name + */ record Match(Handler handler, Map pathParams) implements Resolution { } + /** + * The path matched but no handler is registered for the requested method. + * + * @param allowedMethods the methods that are registered for this path + */ record MethodNotAllowed(Set allowedMethods) implements Resolution { } + /** + * No route matches the requested path. + */ record NotFound() implements Resolution { } } + /** + * Functional contract for a request handler: consumes the incoming {@link Request} and + * mutates the outgoing {@link Response}. + */ @FunctionalInterface public interface Handler { + /** + * Handles a matched request. + * + * @param req the incoming request + * @param res the response to populate + * @throws Exception if handling fails; the request pipeline translates this into an + * appropriate error response + */ void handle(Request req, Response res) throws Exception; } + /** + * A single node in the routing trie. Holds static children keyed by segment, the handlers + * registered at this node, and optional parameter/wildcard children. + */ private static final class Node { + /** Static child nodes keyed by their literal path segment. */ final Map children = new ConcurrentHashMap<>(); + /** Handlers registered directly at this node, keyed by HTTP method. */ final Map handlers = new ConcurrentHashMap<>(); - Node paramChild; + /** Child matching any single segment as a path parameter, or {@code null} if none. */ + Node paramChild; + /** Name under which {@link #paramChild} captures the matched segment. */ String paramName; - Node wildcardChild; + /** Child matching any single segment as a wildcard, or {@code null} if none. */ + Node wildcardChild; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/coph/nextusweb/server/router/exception/BadRequestException.java b/src/main/java/dev/coph/nextusweb/server/router/exception/BadRequestException.java index da55197..a452a53 100644 --- a/src/main/java/dev/coph/nextusweb/server/router/exception/BadRequestException.java +++ b/src/main/java/dev/coph/nextusweb/server/router/exception/BadRequestException.java @@ -1,5 +1,20 @@ package dev.coph.nextusweb.server.router.exception; +/** + * Unchecked exception signalling that an incoming request is malformed and should be answered + * with an HTTP {@code 400 Bad Request}. + * + *

It 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 Set allowedOrigins; + /** Whether connections from any origin are accepted. */ private final boolean allowAnyOrigin; + /** Subprotocols offered during negotiation. */ private final Set subprotocols; + /** Whether per-message deflate compression is enabled. */ private final boolean compression; + /** Whether the protocol handler matches the path by prefix rather than exact equality. */ private final boolean checkStartsWith; + /** + * Builds an immutable configuration from a {@link Builder}, defensively copying its sets. + * + * @param b the builder carrying the configured values + */ private WebSocketConfig(Builder b) { this.maxFramePayloadLength = b.maxFramePayloadLength; this.maxAggregatedMessageSize = b.maxAggregatedMessageSize; @@ -27,110 +48,226 @@ public final class WebSocketConfig { this.checkStartsWith = b.checkStartsWith; } + /** + * Creates a configuration with all default values. + * + * @return a default configuration + */ public static WebSocketConfig defaults() { return builder().build(); } + /** + * Creates a new, empty {@link Builder}. + * + * @return a fresh builder + */ public static Builder builder() { return new Builder(); } + /** + * Tests whether a WebSocket upgrade from the given origin is permitted. + * + * @param origin the request's {@code Origin} header, may be {@code null} + * @return {@code true} if any origin is allowed, or if the origin is in the allow-list; + * {@code false} for a {@code null} or disallowed origin + */ public boolean isOriginAllowed(String origin) { if (allowAnyOrigin) return true; if (origin == null) return false; return allowedOrigins.contains(origin); } + /** + * @return the maximum single-frame payload size in bytes + */ public int maxFramePayloadLength() { return maxFramePayloadLength; } + /** + * @return the maximum aggregated message size in bytes + */ public int maxAggregatedMessageSize() { return maxAggregatedMessageSize; } + /** + * @return the idle timeout, or {@code null} if idle connections are never closed + */ public Duration idleTimeout() { return idleTimeout; } + /** + * @return {@code true} if connections from any origin are accepted + */ public boolean allowAnyOrigin() { return allowAnyOrigin; } + /** + * @return the immutable set of explicitly allowed origins + */ public Set allowedOrigins() { return allowedOrigins; } + /** + * Returns the configured subprotocols as a comma-separated string suitable for Netty's + * protocol config. + * + * @return the comma-separated subprotocol list, or {@code null} if none are configured + */ public String subprotocolsCsv() { if (subprotocols.isEmpty()) return null; return String.join(",", subprotocols); } + /** + * @return {@code true} if per-message compression is enabled + */ public boolean compression() { return compression; } + /** + * @return {@code true} if the WebSocket path is matched by prefix rather than exactly + */ public boolean checkStartsWith() { return checkStartsWith; } + /** + * Fluent builder for {@link WebSocketConfig}, pre-populated with sensible defaults: 64 KiB + * frames, 1 MiB aggregated messages, a 60-second idle timeout, no origin restriction + * list, compression enabled, and exact path matching. + */ public static final class Builder { + /** Maximum single-frame payload size in bytes; defaults to 64 KiB. */ private int maxFramePayloadLength = 65_536; + /** Maximum aggregated message size in bytes; defaults to 1 MiB. */ private int maxAggregatedMessageSize = 1_048_576; + /** Idle timeout; defaults to 60 seconds. */ private Duration idleTimeout = Duration.ofSeconds(60); + /** Accumulated allowed origins (insertion-ordered). */ private final Set allowedOrigins = new LinkedHashSet<>(); + /** Whether any origin is allowed; defaults to {@code false}. */ private boolean allowAnyOrigin = false; + /** Accumulated subprotocols (insertion-ordered). */ private final Set subprotocols = new LinkedHashSet<>(); + /** Whether compression is enabled; defaults to {@code true}. */ private boolean compression = true; + /** Whether path matching uses a prefix check; defaults to {@code false}. */ private boolean checkStartsWith = false; + /** + * Sets the maximum single-frame payload size. + * + * @param bytes the limit in bytes; must be positive + * @return this builder, for fluent chaining + * @throws IllegalArgumentException if {@code bytes <= 0} + */ public Builder maxFramePayloadLength(int bytes) { if (bytes <= 0) throw new IllegalArgumentException("maxFramePayloadLength must be > 0"); this.maxFramePayloadLength = bytes; return this; } + /** + * Sets the maximum aggregated message size. + * + * @param bytes the limit in bytes; must be positive + * @return this builder, for fluent chaining + * @throws IllegalArgumentException if {@code bytes <= 0} + */ public Builder maxAggregatedMessageSize(int bytes) { if (bytes <= 0) throw new IllegalArgumentException("maxAggregatedMessageSize must be > 0"); this.maxAggregatedMessageSize = bytes; return this; } + /** + * Sets the idle timeout after which inactive connections are closed. + * + * @param timeout the idle timeout + * @return this builder, for fluent chaining + */ public Builder idleTimeout(Duration timeout) { this.idleTimeout = timeout; return this; } + /** + * Disables the idle timeout, so connections are never closed for inactivity. + * + * @return this builder, for fluent chaining + */ public Builder noIdleTimeout() { this.idleTimeout = null; return this; } + /** + * Adds one or more origins to the allow-list. + * + * @param origins the origins to allow + * @return this builder, for fluent chaining + */ public Builder allowedOrigins(String... origins) { Collections.addAll(this.allowedOrigins, origins); return this; } + /** + * Allows WebSocket connections from any origin. + * + * @return this builder, for fluent chaining + */ public Builder anyOrigin() { this.allowAnyOrigin = true; return this; } + /** + * Adds one or more subprotocols to offer during negotiation. + * + * @param protocols the subprotocol names + * @return this builder, for fluent chaining + */ public Builder subprotocols(String... protocols) { Collections.addAll(this.subprotocols, protocols); return this; } + /** + * Enables or disables per-message compression. + * + * @param enabled {@code true} to enable compression + * @return this builder, for fluent chaining + */ public Builder compression(boolean enabled) { this.compression = enabled; return this; } + /** + * Sets whether the WebSocket path is matched by prefix rather than exact equality. + * + * @param v {@code true} to match by prefix + * @return this builder, for fluent chaining + */ public Builder checkStartsWith(boolean v) { this.checkStartsWith = v; return this; } + /** + * Builds the immutable {@link WebSocketConfig}. + * + * @return the configured instance + */ public WebSocketConfig build() { return new WebSocketConfig(this); } diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandler.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandler.java index 97d40b1..8aa5eec 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketFrameHandler.java @@ -14,21 +14,53 @@ import java.util.Map; import java.util.concurrent.Executor; import java.util.concurrent.Executors; +/** + * Netty channel handler that bridges low-level WebSocket frames to the high-level + * {@link WebSocketHandler} callbacks. + * + *

It 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 SimpleChannelInboundHandler { - private static final Executor VT_EXECUTOR = - Executors.newVirtualThreadPerTaskExecutor(); + /** Executor running one virtual thread per task, used to dispatch handler callbacks. */ + private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); + /** The application handler receiving lifecycle callbacks. */ private final WebSocketHandler handler; + /** The path the connection was established on. */ private final String path; + /** Path parameters captured during routing, keyed by name. */ private final Map pathParams; + /** + * Creates a frame handler bound to an application handler and connection metadata. + * + * @param handler the application handler to dispatch to + * @param path the connection path + * @param pathParams the captured path parameters + */ WebSocketFrameHandler(WebSocketHandler handler, String path, Map pathParams) { this.handler = handler; this.path = path; this.pathParams = pathParams; } + /** + * Handles pipeline user events. On handshake completion it creates and stores the + * {@link WebSocketSession} and dispatches {@link WebSocketHandler#onOpen}; on an idle-state + * event it closes the channel; other events are passed up the pipeline. + * + * @param ctx the channel context + * @param evt the user event + * @throws Exception if the superclass handling of an unrecognized event fails + */ @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) { @@ -50,6 +82,15 @@ final class WebSocketFrameHandler extends SimpleChannelInboundHandlerIt exists so that other packages (notably {@code HttpRequestHandler} during the upgrade + * handshake) can insert a frame handler into the pipeline without the handler class itself + * having to be public. The class is a stateless utility and cannot be instantiated.

+ */ public final class WebSocketFrameHandlerFactory { + /** + * Private constructor preventing instantiation of this stateless utility class. + */ private WebSocketFrameHandlerFactory() { } + /** + * Creates a channel handler that bridges Netty WebSocket frames to the given application + * {@link WebSocketHandler}. + * + * @param handler the application handler to dispatch lifecycle events to + * @param path the path the connection was established on + * @param pathParams the path parameters captured during routing + * @return a new channel handler ready to be inserted into the pipeline + */ public static ChannelHandler create(WebSocketHandler handler, String path, Map pathParams) { return new WebSocketFrameHandler(handler, path, pathParams); diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketGroup.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketGroup.java index a8ab512..8f0884c 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketGroup.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketGroup.java @@ -8,43 +8,93 @@ import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.util.concurrent.GlobalEventExecutor; import tools.jackson.core.JacksonException; +/** + * A named collection of WebSocket connections that supports broadcasting to all members at + * once — useful for chat rooms, pub/sub topics, presence channels and similar fan-out + * scenarios. + * + *

It 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) { Map params = new HashMap<>(4); Node node = root; @@ -45,6 +69,13 @@ public final class WebSocketRouter { return new Resolution(node.handler, params); } + /** + * Splits a path into its non-empty segments, ignoring leading and collapsing internal + * slashes. + * + * @param path the raw path + * @return the ordered list of path segments + */ private static List split(String path) { List out = new ArrayList<>(); int start = path.startsWith("/") ? 1 : 0; @@ -58,13 +89,27 @@ public final class WebSocketRouter { return out; } + /** + * A successful path resolution. + * + * @param handler the handler bound to the matched path + * @param pathParams the path parameters captured while matching, keyed by name + */ public record Resolution(WebSocketHandler handler, Map pathParams) { } + /** + * A single node in the WebSocket routing trie. Holds static children keyed by segment, an + * optional path-parameter child, and the handler (if any) registered at this node. + */ private static final class Node { + /** Static child nodes keyed by their literal path segment. */ final Map children = new ConcurrentHashMap<>(); + /** Child matching any single segment as a path parameter, or {@code null} if none. */ Node paramChild; + /** Name under which {@link #paramChild} captures the matched segment. */ String paramName; + /** Handler registered at this node, or {@code null} if the path is only a prefix. */ WebSocketHandler handler; } } diff --git a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketSession.java b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketSession.java index fe76639..7c8aced 100644 --- a/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketSession.java +++ b/src/main/java/dev/coph/nextusweb/server/websocket/WebSocketSession.java @@ -20,17 +20,44 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +/** + * Represents a single, live WebSocket connection and is the primary object application handlers + * interact with. + * + *

It 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 SESSION_KEY = AttributeKey.valueOf("nexusweb.ws.session"); + /** The underlying Netty channel for this connection. */ private final Channel channel; + /** Unique identifier generated for this session. */ private final String id; + /** The path the connection was established on. */ private final String path; + /** Path parameters captured during routing, keyed by name. */ private final Map pathParams; + /** Thread-safe bag of user-defined attributes attached to the session. */ private final Map attributes = new ConcurrentHashMap<>(); + /** + * Creates a session for a freshly upgraded channel. Package-private; created by the frame + * handler once the handshake completes. + * + * @param channel the underlying Netty channel + * @param path the connection path + * @param pathParams the path parameters captured during routing + */ WebSocketSession(Channel channel, String path, Map pathParams) { this.channel = channel; this.id = UUID.randomUUID().toString(); @@ -38,22 +65,43 @@ public final class WebSocketSession { this.pathParams = pathParams; } + /** + * @return the unique session id + */ public String id() { return id; } + /** + * @return the path the connection was established on + */ public String path() { return path; } + /** + * Returns the value of a path parameter captured during routing. + * + * @param name the parameter name (without braces) + * @return the captured value, or {@code null} if there is no such parameter + */ public String pathParam(String name) { return pathParams.get(name); } + /** + * @return {@code true} if the underlying channel is still active (open) + */ public boolean isOpen() { return channel.isActive(); } + /** + * Returns the peer's remote IP address. + * + * @return the remote host address, or a string form of the address if it is not an + * {@link InetSocketAddress}; {@code null} if unavailable + */ public String remoteAddress() { SocketAddress addr = channel.remoteAddress(); if (addr instanceof InetSocketAddress inet) { @@ -62,26 +110,59 @@ public final class WebSocketSession { return addr == null ? null : addr.toString(); } + /** + * @return the underlying Netty channel, for advanced use + */ public Channel channel() { return channel; } + /** + * Associates a user-defined attribute with this session, or removes it when {@code value} is + * {@code null}. + * + * @param name the attribute name + * @param value the value to store, or {@code null} to remove the attribute + * @return this session, for fluent chaining + */ public WebSocketSession attribute(String name, Object value) { if (value == null) attributes.remove(name); else attributes.put(name, value); return this; } + /** + * Retrieves a previously stored attribute, cast to the caller's expected type. + * + * @param name the attribute name + * @param the expected attribute type + * @return the stored value, or {@code null} if absent + */ @SuppressWarnings("unchecked") public T attribute(String name) { return (T) attributes.get(name); } + /** + * Sends a text message to the peer. + * + * @param text the text to send + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + */ public ChannelFuture send(String text) { if (!channel.isActive()) return channel.newSucceededFuture(); return channel.writeAndFlush(new TextWebSocketFrame(text)); } + /** + * Serializes the given value to JSON and sends it as a text message. + * + * @param value the object to serialize and send + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + * @throws RuntimeException if JSON serialization fails + */ public ChannelFuture sendJson(Object value) { try { byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(value); @@ -93,27 +174,63 @@ public final class WebSocketSession { } } + /** + * Sends a binary message to the peer. + * + * @param data the bytes to send + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + */ public ChannelFuture sendBinary(byte[] data) { if (!channel.isActive()) return channel.newSucceededFuture(); ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(data); return channel.writeAndFlush(new BinaryWebSocketFrame(buf)); } + /** + * Sends a WebSocket ping frame to the peer (e.g. as a keep-alive). + * + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + */ public ChannelFuture ping() { if (!channel.isActive()) return channel.newSucceededFuture(); return channel.writeAndFlush(new PingWebSocketFrame()); } + /** + * Closes the connection with the normal-closure status code {@code 1000} and no reason. + * + * @return a future completing when the close frame has been written + */ public ChannelFuture close() { return close(1000, ""); } + /** + * Closes the connection with an explicit status code and reason, closing the channel once + * the close frame has been written. + * + * @param code the WebSocket close status code + * @param reason the human-readable close reason + * @return a future completing when the close frame has been written; an already-succeeded + * future if the channel is no longer active + */ public ChannelFuture close(int code, String reason) { if (!channel.isActive()) return channel.newSucceededFuture(); return channel.writeAndFlush(new CloseWebSocketFrame(code, reason)) .addListener(ChannelFutureListener.CLOSE); } + /** + * Low-level helper that writes a text payload directly to a channel, allocating the buffer + * from the channel's allocator. Used by collaborators that hold a channel but not a session. + * + * @param channel the channel to write to + * @param text the text to send + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + */ static ChannelFuture sendRaw(Channel channel, String text) { if (!channel.isActive()) return channel.newSucceededFuture(); ByteBuf buf = channel.alloc().buffer(); @@ -121,6 +238,14 @@ public final class WebSocketSession { return channel.writeAndFlush(new TextWebSocketFrame(true, 0, buf)); } + /** + * Low-level helper that writes a binary payload directly to a channel. + * + * @param channel the channel to write to + * @param data the bytes to send + * @return a future completing when the write finishes; an already-succeeded future if the + * channel is no longer active + */ static ChannelFuture sendRawBinary(Channel channel, byte[] data) { if (!channel.isActive()) return channel.newSucceededFuture(); ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(Unpooled.wrappedBuffer(data));