From c10a395a12f456bb670db2af2dd360377c852864 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 16:14:12 +0200 Subject: [PATCH] docs: Eliminate all maven-javadoc-plugin warnings Adds detailed but concise javadoc across the public API and internal implementation classes so `mvn package` runs the attached-javadocs goal without any warnings (previously 174). No code behaviour changes. Also adds an explicit no-arg constructor with javadoc to FormTypeMapper, FormUrlEncodedParser, NotFoundHandler, TextPlainParser, and TextTypeMapper so the synthesised default constructor no longer trips doclint, and imports ValidationException in Validator to resolve the {@link} references in its contract javadoc. --- .../com/retailsvc/http/AfterResponseHook.java | 13 ++ .../retailsvc/http/BadRequestException.java | 58 +++++++ .../java/com/retailsvc/http/Credential.java | 16 ++ .../java/com/retailsvc/http/Dependency.java | 5 + .../com/retailsvc/http/ExceptionHandler.java | 8 + .../com/retailsvc/http/GsonTypeMapper.java | 3 + .../java/com/retailsvc/http/Handlers.java | 20 ++- .../com/retailsvc/http/HealthOutcome.java | 7 +- .../http/Jackson2JsonTypeMapper.java | 5 + .../http/Jackson3JsonTypeMapper.java | 5 + .../http/MethodNotAllowedException.java | 12 ++ .../MissingOperationHandlerException.java | 6 + .../com/retailsvc/http/NotFoundException.java | 6 + .../com/retailsvc/http/OpenApiServer.java | 79 +++++++++ src/main/java/com/retailsvc/http/Request.java | 62 ++++++- .../com/retailsvc/http/RequestHandler.java | 8 +- .../retailsvc/http/RequestInterceptor.java | 12 ++ .../java/com/retailsvc/http/Response.java | 162 ++++++++++++++++-- .../com/retailsvc/http/ResponseDecorator.java | 9 +- .../com/retailsvc/http/SchemeValidator.java | 7 + .../java/com/retailsvc/http/TypeMapper.java | 12 +- .../com/retailsvc/http/TypedTypeMapper.java | 4 +- .../retailsvc/http/ValidationException.java | 11 ++ .../retailsvc/http/internal/BodyWriter.java | 31 +++- .../http/internal/ContentTypeHeader.java | 12 +- .../http/internal/DispatchHandler.java | 37 ++++ .../http/internal/ExceptionFilter.java | 14 ++ .../http/internal/ExtraRouteAdapter.java | 6 + .../http/internal/FormTypeMapper.java | 7 + .../http/internal/FormUrlEncodedParser.java | 14 +- .../http/internal/HealthRenderer.java | 7 + .../http/internal/NotFoundHandler.java | 7 +- .../http/internal/ProblemDetail.java | 33 ++++ .../internal/RequestPreparationFilter.java | 16 ++ .../http/internal/ResourceSource.java | 57 ++++++ .../http/internal/ResponseRenderer.java | 13 ++ .../com/retailsvc/http/internal/Router.java | 26 +++ .../http/internal/SecurityFilter.java | 16 ++ .../http/internal/TextPlainParser.java | 12 ++ .../http/internal/TextTypeMapper.java | 5 + .../http/internal/ValueCoercion.java | 9 + .../http/internal/gson/GsonJsonMapper.java | 12 +- .../com/retailsvc/http/spec/HttpMethod.java | 16 ++ .../java/com/retailsvc/http/spec/Info.java | 7 + .../com/retailsvc/http/spec/MediaType.java | 5 + .../com/retailsvc/http/spec/Operation.java | 13 ++ .../com/retailsvc/http/spec/Parameter.java | 22 +++ .../com/retailsvc/http/spec/PathTemplate.java | 20 +++ .../com/retailsvc/http/spec/RequestBody.java | 6 + .../com/retailsvc/http/spec/Response.java | 5 + .../java/com/retailsvc/http/spec/Server.java | 11 ++ .../java/com/retailsvc/http/spec/Spec.java | 45 +++++ .../spec/schema/AdditionalProperties.java | 34 ++++ .../http/spec/schema/AllOfSchema.java | 14 ++ .../http/spec/schema/AlwaysSchema.java | 14 ++ .../http/spec/schema/AnyOfSchema.java | 18 ++ .../http/spec/schema/ArraySchema.java | 15 ++ .../http/spec/schema/BooleanSchema.java | 14 ++ .../http/spec/schema/ConstSchema.java | 9 + .../http/spec/schema/EnumSchema.java | 14 ++ .../http/spec/schema/IntegerSchema.java | 12 ++ .../http/spec/schema/NeverSchema.java | 6 + .../retailsvc/http/spec/schema/NotSchema.java | 6 + .../http/spec/schema/NullSchema.java | 5 + .../http/spec/schema/NumberSchema.java | 12 ++ .../http/spec/schema/ObjectSchema.java | 11 ++ .../http/spec/schema/OneOfSchema.java | 6 + .../retailsvc/http/spec/schema/RefSchema.java | 6 + .../retailsvc/http/spec/schema/Schema.java | 24 +++ .../http/spec/schema/SchemaParser.java | 8 + .../http/spec/schema/StringSchema.java | 11 ++ .../retailsvc/http/spec/schema/TypeName.java | 15 ++ .../spec/security/SecurityRequirement.java | 6 + .../http/spec/security/SecurityScheme.java | 56 +++++- .../spec/security/SecuritySchemeParser.java | 18 ++ .../http/validate/DefaultValidator.java | 49 ++++++ .../http/validate/ValidationError.java | 8 + .../retailsvc/http/validate/Validator.java | 19 +- 78 files changed, 1391 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/retailsvc/http/AfterResponseHook.java b/src/main/java/com/retailsvc/http/AfterResponseHook.java index e08b35b..fd4b790 100644 --- a/src/main/java/com/retailsvc/http/AfterResponseHook.java +++ b/src/main/java/com/retailsvc/http/AfterResponseHook.java @@ -18,5 +18,18 @@ @FunctionalInterface public interface AfterResponseHook { + /** + * Invoked after the response has been written to the client. + * + *

Any exception thrown by this method is logged at DEBUG and swallowed; it does not affect the + * response (already sent) and does not prevent subsequent hooks from running. + * + * @param request the resolved {@link Request} that was handled; routing and parameter/body + * validation have already passed by the time this is called + * @param response the {@link Response} that was written to the client. {@link Response#status()} + * and {@link Response#headers()} reflect what was sent on the wire; {@link Response#body()} + * may be {@code null} on streaming responses (and is always {@code null} on the error path, + * since the body bytes have already been emitted) + */ void after(Request request, Response response); } diff --git a/src/main/java/com/retailsvc/http/BadRequestException.java b/src/main/java/com/retailsvc/http/BadRequestException.java index 4abaa69..2cbd8fd 100644 --- a/src/main/java/com/retailsvc/http/BadRequestException.java +++ b/src/main/java/com/retailsvc/http/BadRequestException.java @@ -16,18 +16,58 @@ public final class BadRequestException extends RuntimeException { private static final int DEFAULT_STATUS = 400; + /** HTTP 4xx status code carried by this exception. */ private final int status; + + /** JSON Pointer (RFC 6901) to the offending property; may be {@code null}. */ private final String pointer; + + /** Validation keyword that failed (for example {@code "required"}); may be {@code null}. */ private final String keyword; + /** + * Creates a new exception with the default HTTP status {@code 400 Bad Request}. + * + *

Equivalent to {@link #BadRequestException(int, String, String, String)} invoked with {@code + * status = 400} and no pointer or keyword. + * + * @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail} + * member + */ public BadRequestException(String detail) { this(DEFAULT_STATUS, detail, null, null); } + /** + * Creates a new exception with an explicit 4xx HTTP status. + * + *

Equivalent to {@link #BadRequestException(int, String, String, String)} with no pointer or + * keyword. + * + * @param status the HTTP status code; must be in the range {@code 400}-{@code 499} or an {@link + * IllegalArgumentException} is thrown + * @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail} + * member + */ public BadRequestException(int status, String detail) { this(status, detail, null, null); } + /** + * Creates a new exception with the full set of RFC 7807 problem fields. Intended for + * validation-style errors where the offending property can be pinpointed with a JSON Pointer and + * the failing rule identified by a JSON Schema keyword. + * + * @param status the HTTP status code; must be in the range {@code 400}-{@code 499} + * @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail} + * member + * @param pointer JSON Pointer (RFC 6901) to the offending property in the request payload; may be + * {@code null} when not applicable + * @param keyword JSON Schema / validation keyword that failed (for example {@code "required"}, + * {@code "pattern"}, {@code "maxLength"}); may be {@code null} when not applicable + * @throws NullPointerException if {@code detail} is {@code null} + * @throws IllegalArgumentException if {@code status} is not in the range {@code 400}-{@code 499} + */ public BadRequestException(int status, String detail, String pointer, String keyword) { super(Objects.requireNonNull(detail, "detail must not be null")); if (status < 400 || status > 499) { @@ -38,14 +78,32 @@ public BadRequestException(int status, String detail, String pointer, String key this.keyword = keyword; } + /** + * Returns the HTTP status code carried by this exception. + * + * @return the HTTP status code; always a 4xx value in the range {@code 400}-{@code 499} + */ public int status() { return status; } + /** + * Returns the JSON Pointer locating the offending property in the request payload. + * + * @return an {@link Optional} containing the JSON Pointer (RFC 6901) to the offending property, + * or {@link Optional#empty()} when no pointer was supplied + */ public Optional pointer() { return Optional.ofNullable(pointer); } + /** + * Returns the JSON Schema / validation keyword that failed. + * + * @return an {@link Optional} containing the failing JSON Schema or validation keyword (for + * example {@code "required"}, {@code "pattern"}), or {@link Optional#empty()} when no keyword + * was supplied + */ public Optional keyword() { return Optional.ofNullable(keyword); } diff --git a/src/main/java/com/retailsvc/http/Credential.java b/src/main/java/com/retailsvc/http/Credential.java index c3b1134..5c5ca99 100644 --- a/src/main/java/com/retailsvc/http/Credential.java +++ b/src/main/java/com/retailsvc/http/Credential.java @@ -7,9 +7,25 @@ public sealed interface Credential permits Credential.ApiKeyCredential, Credential.BearerCredential, Credential.BasicCredential { + /** + * Credential extracted from an OpenAPI {@code apiKey} security scheme (header, query, or cookie). + * + * @param value raw key value as presented by the client. + */ record ApiKeyCredential(String value) implements Credential {} + /** + * Credential extracted from an {@code http} security scheme with {@code bearer} style. + * + * @param token raw bearer token (no {@code Bearer } prefix). + */ record BearerCredential(String token) implements Credential {} + /** + * Credential extracted from an {@code http} security scheme with {@code basic} style. + * + * @param username decoded username portion. + * @param password decoded password portion. + */ record BasicCredential(String username, String password) implements Credential {} } diff --git a/src/main/java/com/retailsvc/http/Dependency.java b/src/main/java/com/retailsvc/http/Dependency.java index 54458f3..9d82f80 100644 --- a/src/main/java/com/retailsvc/http/Dependency.java +++ b/src/main/java/com/retailsvc/http/Dependency.java @@ -12,6 +12,11 @@ * @param up whether the dependency is healthy */ public record Dependency(String id, boolean up) { + /** + * Canonical constructor that validates {@code id} is non-null. + * + * @throws NullPointerException if {@code id} is {@code null} + */ public Dependency { Objects.requireNonNull(id, "id"); } diff --git a/src/main/java/com/retailsvc/http/ExceptionHandler.java b/src/main/java/com/retailsvc/http/ExceptionHandler.java index 02f42dc..f911f2d 100644 --- a/src/main/java/com/retailsvc/http/ExceptionHandler.java +++ b/src/main/java/com/retailsvc/http/ExceptionHandler.java @@ -10,5 +10,13 @@ @FunctionalInterface public interface ExceptionHandler { + /** + * Maps the throwable to the {@link Response} to render. + * + * @param t the exception thrown anywhere in the request pipeline (never null) + * @return the response to write; library default maps {@code BadRequestException} to 4xx + * problem+json and anything else to 500 problem+json with the exception message redacted by + * default + */ Response handle(Throwable t); } diff --git a/src/main/java/com/retailsvc/http/GsonTypeMapper.java b/src/main/java/com/retailsvc/http/GsonTypeMapper.java index fff3bac..36023bd 100644 --- a/src/main/java/com/retailsvc/http/GsonTypeMapper.java +++ b/src/main/java/com/retailsvc/http/GsonTypeMapper.java @@ -37,6 +37,7 @@ public GsonTypeMapper() { /** * Creates a mapper backed by the supplied {@link Gson}. * + * @param gson the {@link Gson} instance to delegate to * @throws NullPointerException if {@code gson} is null */ public GsonTypeMapper(Gson gson) { @@ -47,6 +48,8 @@ public GsonTypeMapper(Gson gson) { * Returns a {@link GsonBuilder} pre-populated with the wrapped {@link Gson}'s configuration, so * callers can derive a customized {@link Gson} from the library default (or from their own * starting point). + * + * @return a new {@link GsonBuilder} seeded with the wrapped {@link Gson}'s configuration */ public GsonBuilder gsonBuilder() { return delegate.gson().newBuilder(); diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 8ca9abd..7334130 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -21,12 +21,20 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** Built-in {@link RequestHandler} and {@link ExceptionHandler} factories. */ public final class Handlers { private static final Logger LOG = LoggerFactory.getLogger(Handlers.class); private Handlers() {} + /** + * Default {@link ExceptionHandler} that maps common library exceptions to RFC 7807 problem + * responses and falls back to a 500 for anything unrecognised. + * + * @param jsonMapper mapper used to serialise problem detail bodies + * @return the exception handler + */ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) { Objects.requireNonNull(jsonMapper, "jsonMapper must not be null"); return t -> @@ -54,7 +62,12 @@ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) { }; } - /** Returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} otherwise. */ + /** + * Liveness handler that returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} + * otherwise. + * + * @return the liveness handler + */ public static RequestHandler aliveHandler() { return req -> switch (req.method()) { @@ -79,6 +92,7 @@ public static RequestHandler aliveHandler() { *

The body is rendered by a built-in writer; no JSON library on the classpath is required. * * @param probe supplier of the current {@link HealthOutcome} + * @return the health handler */ public static RequestHandler healthHandler(Supplier probe) { Objects.requireNonNull(probe, "probe"); @@ -110,6 +124,7 @@ public static RequestHandler healthHandler(Supplier probe) { * request — the handler owns the stream lifecycle. * * @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml} + * @return the spec handler */ public static RequestHandler resourceHandler(String classpathResource) { return resourceHandler(ResourceSource.ofClasspath(classpathResource)); @@ -119,6 +134,9 @@ public static RequestHandler resourceHandler(String classpathResource) { * Serves a filesystem file as a streaming response. Content-Type is inferred from the file * extension. Existence and length are resolved at construction; a missing file fails immediately * with {@link IllegalArgumentException}. The file is opened and closed per request. + * + * @param file path to the file to serve + * @return a handler that streams {@code file} on {@code GET} */ public static RequestHandler resourceHandler(Path file) { return resourceHandler(ResourceSource.ofFile(file)); diff --git a/src/main/java/com/retailsvc/http/HealthOutcome.java b/src/main/java/com/retailsvc/http/HealthOutcome.java index 9db0c05..951af7f 100644 --- a/src/main/java/com/retailsvc/http/HealthOutcome.java +++ b/src/main/java/com/retailsvc/http/HealthOutcome.java @@ -14,11 +14,16 @@ */ public record HealthOutcome(List dependencies) { + /** Canonical constructor; normalises a {@code null} dependency list to an empty list. */ public HealthOutcome { dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); } - /** {@code true} when every dependency is up (vacuously true for an empty list). */ + /** + * Reports the aggregate health derived from {@link #dependencies()}. + * + * @return {@code true} when every dependency is up (vacuously true for an empty list) + */ public boolean up() { return dependencies.stream().allMatch(Dependency::up); } diff --git a/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java b/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java index 38e9ed1..2081615 100644 --- a/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java +++ b/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java @@ -31,6 +31,11 @@ public final class Jackson2JsonTypeMapper implements TypedTypeMapper { private final ObjectMapper mapper; + /** + * Creates a new mapper backed by the given Jackson {@link ObjectMapper}. + * + * @param mapper the fully-configured Jackson mapper to delegate to; must not be {@code null} + */ public Jackson2JsonTypeMapper(ObjectMapper mapper) { this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); } diff --git a/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java b/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java index 57f232b..741a7df 100644 --- a/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java +++ b/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java @@ -37,6 +37,11 @@ public final class Jackson3JsonTypeMapper implements TypedTypeMapper { private final ObjectMapper mapper; + /** + * Creates a new mapper backed by the given Jackson 3 {@link ObjectMapper}. + * + * @param mapper the fully-configured Jackson 3 mapper to delegate to; must not be {@code null} + */ public Jackson3JsonTypeMapper(ObjectMapper mapper) { this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); } diff --git a/src/main/java/com/retailsvc/http/MethodNotAllowedException.java b/src/main/java/com/retailsvc/http/MethodNotAllowedException.java index bc757fd..0371faf 100644 --- a/src/main/java/com/retailsvc/http/MethodNotAllowedException.java +++ b/src/main/java/com/retailsvc/http/MethodNotAllowedException.java @@ -3,14 +3,26 @@ import com.retailsvc.http.spec.HttpMethod; import java.util.Set; +/** Thrown when a request targets a known path with an HTTP method that is not declared for it. */ public final class MethodNotAllowedException extends RuntimeException { + /** The set of HTTP methods that the matched path accepts. */ private final Set allowed; + /** + * Creates a new exception carrying the methods the path actually accepts. + * + * @param allowed methods declared for the matched path + */ public MethodNotAllowedException(Set allowed) { super("method not allowed; allowed=" + allowed); this.allowed = Set.copyOf(allowed); } + /** + * Returns the HTTP methods allowed for the matched path. + * + * @return immutable set of allowed methods + */ public Set allowed() { return allowed; } diff --git a/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java b/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java index 7b4f237..4532650 100644 --- a/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java +++ b/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java @@ -1,6 +1,12 @@ package com.retailsvc.http; +/** Thrown when a request resolves to an OpenAPI operationId that has no registered handler. */ public final class MissingOperationHandlerException extends RuntimeException { + /** + * Creates a new exception identifying the unregistered operation. + * + * @param operationId the OpenAPI operationId with no handler bound + */ public MissingOperationHandlerException(String operationId) { super("no handler registered for operationId=" + operationId); } diff --git a/src/main/java/com/retailsvc/http/NotFoundException.java b/src/main/java/com/retailsvc/http/NotFoundException.java index ea3f82e..7159ece 100644 --- a/src/main/java/com/retailsvc/http/NotFoundException.java +++ b/src/main/java/com/retailsvc/http/NotFoundException.java @@ -1,6 +1,12 @@ package com.retailsvc.http; +/** Thrown when a request path does not match any route declared in the OpenAPI spec. */ public final class NotFoundException extends RuntimeException { + /** + * Creates a new exception with the given detail message. + * + * @param message human-readable explanation of the missing route + */ public NotFoundException(String message) { super(message); } diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 12371ef..ef2f5db 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -143,6 +143,11 @@ record HandlerConfig( System.currentTimeMillis() - t0); } + /** + * Returns the TCP port the server is listening on. Useful when bound to an ephemeral port. + * + * @return the active listen port + */ public int listenPort() { return httpServer.getAddress().getPort(); } @@ -150,6 +155,8 @@ public int listenPort() { /** * Returns the local address the server is bound to. For a wildcard-bound server this is the * wildcard address; for a loopback-bound server this is the loopback address. + * + * @return the bound local {@link InetAddress} */ public InetAddress bindAddress() { return httpServer.getAddress().getAddress(); @@ -175,6 +182,11 @@ public void close() { stop(shutdownTimeoutSeconds); } + /** + * Creates a new fluent {@link Builder} for configuring and starting an {@link OpenApiServer}. + * + * @return a fresh builder + */ public static Builder builder() { return new Builder(); } @@ -198,11 +210,24 @@ public static final class Builder { private Builder() {} + /** + * Sets the parsed OpenAPI {@link Spec} the server will expose. Required. + * + * @param spec the parsed OpenAPI specification + * @return this builder + */ public Builder spec(Spec spec) { this.spec = spec; return this; } + /** + * Registers a {@link TypeMapper} for the given request/response media type. + * + * @param mediaType the media type (e.g. {@code application/json}); case-insensitive + * @param mapper the mapper to use for that media type + * @return this builder + */ public Builder bodyMapper(String mediaType, TypeMapper mapper) { requireNonNull(mediaType, "mediaType must not be null"); requireNonNull(mapper, "mapper must not be null"); @@ -210,10 +235,23 @@ public Builder bodyMapper(String mediaType, TypeMapper mapper) { return this; } + /** + * Convenience shortcut for {@link #bodyMapper(String, TypeMapper) + * bodyMapper("application/json", mapper)}. + * + * @param mapper the JSON {@link TypeMapper} + * @return this builder + */ public Builder jsonMapper(TypeMapper mapper) { return bodyMapper(JSON, mapper); } + /** + * Sets the map of OpenAPI {@code operationId} to {@link RequestHandler}. Required. + * + * @param handlers the operation-id to handler map + * @return this builder + */ public Builder handlers(Map handlers) { this.handlers = handlers; return this; @@ -223,6 +261,9 @@ public Builder handlers(Map handlers) { * Registers a {@link ResponseDecorator} that transforms the {@link Response} returned by the * handler before it is rendered. Decorators compose in registration order; decorator-supplied * headers override handler-supplied ones on conflict. + * + * @param decorator the response decorator to register + * @return this builder */ public Builder responseDecorator(ResponseDecorator decorator) { decorators.add(requireNonNull(decorator, "decorator must not be null")); @@ -232,6 +273,9 @@ public Builder responseDecorator(ResponseDecorator decorator) { /** * Registers a {@link RequestInterceptor} that wraps the handler invocation. Interceptors run in * registration order; the first registered is the outermost. + * + * @param interceptor the request interceptor to register + * @return this builder */ public Builder interceptor(RequestInterceptor interceptor) { interceptors.add(requireNonNull(interceptor, "interceptor must not be null")); @@ -243,6 +287,9 @@ public Builder interceptor(RequestInterceptor interceptor) { * request thread inside the library's request scope, in registration order, with all exceptions * swallowed. Hooks fire only when a {@link Request} was successfully built — pre-request * failures (404, 405, 400 validation) do not fire hooks. + * + * @param hook the after-response hook to register + * @return this builder */ public Builder afterResponseHook(AfterResponseHook hook) { afterHooks.add(requireNonNull(hook, "hook must not be null")); @@ -254,6 +301,10 @@ public Builder afterResponseHook(AfterResponseHook hook) { * The library extracts a {@link Credential} per request and hands it to this callback; return a * non-empty {@link Optional} carrying the principal on success, or {@link Optional#empty()} to * deny. Library renders 401/403 on denial. + * + * @param schemeName the OpenAPI security scheme name this validator authenticates + * @param validator the validator that resolves the credential to a principal + * @return this builder */ public Builder securityValidator(String schemeName, SchemeValidator validator) { requireNonNull(schemeName, "schemeName must not be null"); @@ -267,12 +318,21 @@ public Builder securityValidator(String schemeName, SchemeValidator validator) { * authenticates requests upstream. The library still parses {@code securitySchemes} into the * {@link Spec}, but {@code SecurityFilter} short-circuits and the boot-time * validator-registration check is skipped. + * + * @return this builder */ public Builder useExternalAuthentication() { this.externalAuth = true; return this; } + /** + * Sets the {@link ExceptionHandler} used to render uncaught handler exceptions. When unset, a + * default JSON {@code application/problem+json} handler is used. + * + * @param exceptionHandler the exception handler + * @return this builder + */ public Builder exceptionHandler(ExceptionHandler exceptionHandler) { this.exceptionHandler = exceptionHandler; return this; @@ -281,6 +341,9 @@ public Builder exceptionHandler(ExceptionHandler exceptionHandler) { /** * Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} when not set. Use {@code * 0} to bind on an ephemeral port (read it back via {@link OpenApiServer#listenPort()}). + * + * @param port the TCP port to bind, or {@code 0} for an ephemeral port + * @return this builder */ public Builder port(int port) { this.port = port; @@ -291,6 +354,9 @@ public Builder port(int port) { * Restricts the server to a specific local interface. {@code null} (the default) binds to the * wildcard address (all interfaces). Use {@link InetAddress#getLoopbackAddress()} to listen on * loopback only. + * + * @param bindAddress the local interface to bind, or {@code null} for the wildcard address + * @return this builder */ public Builder bindAddress(InetAddress bindAddress) { this.bindAddress = bindAddress; @@ -301,6 +367,9 @@ public Builder bindAddress(InetAddress bindAddress) { * Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default) * stops immediately; positive values wait up to that many seconds for in-flight exchanges to * finish. + * + * @param shutdownTimeoutSeconds non-negative drain timeout in seconds + * @return this builder */ public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) { if (shutdownTimeoutSeconds < 0) { @@ -316,6 +385,10 @@ public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) { * Use for side concerns like {@code /alive}, {@code /health}, or serving the spec itself — * anything that isn't an OpenAPI {@code operationId}. For OpenAPI-described operations use * {@link #handlers(Map)}. + * + * @param path the exact context path to expose + * @param handler the handler to invoke for that path + * @return this builder */ public Builder extraRoute(String path, RequestHandler handler) { requireNonNull(path, "path must not be null"); @@ -327,6 +400,12 @@ public Builder extraRoute(String path, RequestHandler handler) { return this; } + /** + * Builds and starts the {@link OpenApiServer} with the configured settings. + * + * @return a started server bound to the configured address and port + * @throws IOException if the underlying {@link HttpServer} cannot be created or bound + */ public OpenApiServer build() throws IOException { requireNonNull(spec, "Spec must not be null"); requireNonNull(handlers, "handlers must not be null"); diff --git a/src/main/java/com/retailsvc/http/Request.java b/src/main/java/com/retailsvc/http/Request.java index 13d0404..de68947 100644 --- a/src/main/java/com/retailsvc/http/Request.java +++ b/src/main/java/com/retailsvc/http/Request.java @@ -179,12 +179,19 @@ public Request( this.afterHooks = afterHooks; } + /** + * Raw request body bytes. + * + * @return body bytes; never {@code null}, may be empty + */ public byte[] bytes() { return body; } /** * Loose structural view of the body (typically a {@code Map} / {@code List} / boxed primitive). + * + * @return parsed body, or {@code null} if there is no body */ public Object parsed() { return parsed; @@ -198,6 +205,9 @@ public Object parsed() { * the loose {@link #parsed()} value already is an instance of {@code type}, it is returned * directly without re-deserialising. * + * @param target POJO type + * @param type target class to deserialise the body into + * @return body deserialised as {@code type} * @throws NullPointerException if {@code type} is null * @throws IllegalStateException if there is no body, or if the body mapper does not implement * {@link TypedTypeMapper} @@ -223,20 +233,37 @@ public T asPojo(Class type) { /** * Value of the {@code Content-Type} request header, or {@link Optional#empty()} if absent or * blank. Convenience for {@code header("Content-Type")} — the most frequently inspected header. + * + * @return content type value, or empty if absent or blank */ public Optional contentType() { return header(CONTENT_TYPE); } + /** + * OpenAPI {@code operationId} the request was routed to. + * + * @return operation ID + */ public String operationId() { return operationId; } + /** + * Path variables extracted by the router, keyed by parameter name. + * + * @return path parameter map + */ public Map pathParams() { return pathParameters; } - /** Value of the path parameter {@code name}, or {@code null} if absent. */ + /** + * Value of the path parameter {@code name}, or {@code null} if absent. + * + * @param name path parameter name + * @return decoded value, or {@code null} if absent + */ public String pathParam(String name) { return pathParameters.get(name); } @@ -245,6 +272,9 @@ public String pathParam(String name) { * First value of the request header {@code name}, or {@link Optional#empty()} if absent or blank. * Blank values are treated as missing so callers can write {@code req.header("X").map(...)} * without the extra {@code filter(v -> !v.isBlank())} step. + * + * @param name header name (case-insensitive) + * @return first header value, or empty if absent or blank */ public Optional header(String name) { String raw = headerLookup.apply(name); @@ -254,6 +284,8 @@ public Optional header(String name) { /** * Raw (percent-encoded) query string from the request URI, or {@code null} if the URI has no * query component. + * + * @return raw query string, or {@code null} if absent */ public String rawQuery() { return rawQuery; @@ -262,6 +294,8 @@ public String rawQuery() { /** * Decoded query parameters keyed by name. Empty if the URI has no query. For repeated keys, the * first occurrence wins. Values are URL-decoded with UTF-8. + * + * @return decoded query parameter map */ public Map queryParams() { if (queryParamCache == null) { @@ -275,6 +309,9 @@ public Map queryParams() { * blank. Blank values are treated as missing so callers can write {@code * req.queryParam("limit").map(Integer::parseInt).orElse(DEFAULT)} without the extra {@code * filter(v -> !v.isBlank())} step. + * + * @param name query parameter name + * @return first decoded value, or empty if absent or blank */ public Optional queryParam(String name) { String raw = queryParams().get(name); @@ -284,12 +321,19 @@ public Optional queryParam(String name) { /** * Principals stashed by {@code SecurityFilter}, keyed by securityScheme name. Empty when the * request had no security requirements or when {@code useExternalAuthentication()} is set. + * + * @return principals keyed by security scheme name */ public Map principals() { return principals; } - /** Convenience for the common single-scheme case. */ + /** + * Convenience for the common single-scheme case. + * + * @param schemeName OpenAPI security scheme name + * @return principal for that scheme, or empty if absent + */ public Optional principal(String schemeName) { return Optional.ofNullable(principals.get(schemeName)); } @@ -298,6 +342,8 @@ public Optional principal(String schemeName) { * HTTP method of the request. Never {@code null} for requests routed through the standard * pipeline; {@code null} only when the {@code Request} was constructed via a legacy constructor * without a method. + * + * @return HTTP method, or {@code null} for legacy constructions */ public HttpMethod method() { return method; @@ -307,6 +353,9 @@ public HttpMethod method() { * Returns a new {@code Request} identical to this one except with the supplied principals. Used * by {@code SecurityFilter} on success; the returned instance carries the principals through to * the {@link RequestHandler}. + * + * @param principals principals keyed by security scheme name + * @return new {@code Request} carrying the supplied principals */ public Request withPrincipals(Map principals) { return new Request( @@ -330,6 +379,7 @@ public Request withPrincipals(Map principals) { *

Calls made after the runner has snapshotted the queue (e.g. from inside a running hook, or * from a leaked {@code Request} reference held past the response) are silently ignored. * + * @param runnable runnable to execute after the response is sent * @throws NullPointerException if {@code runnable} is null */ public void afterResponse(Runnable runnable) { @@ -338,9 +388,11 @@ public void afterResponse(Runnable runnable) { } /** - * Returns an unmodifiable view of the queued after-response runnables. Intended for the framework - * runner; consumers should use {@link #afterResponse(Runnable)} to register runnables rather than - * inspecting this list directly. + * Returns an unmodifiable view of the queued after-response runnables. Intended for the server's + * dispatch runner; consumers should use {@link #afterResponse(Runnable)} to register runnables + * rather than inspecting this list directly. + * + * @return unmodifiable view of the queued runnables, in registration order */ public List afterHooks() { return Collections.unmodifiableList(afterHooks); diff --git a/src/main/java/com/retailsvc/http/RequestHandler.java b/src/main/java/com/retailsvc/http/RequestHandler.java index 43493d6..c2ce702 100644 --- a/src/main/java/com/retailsvc/http/RequestHandler.java +++ b/src/main/java/com/retailsvc/http/RequestHandler.java @@ -5,12 +5,18 @@ * OpenApiServer.Builder#handlers(java.util.Map)} by operation ID. * *

Handlers are pure functions of the {@link Request}: they read inputs and return a {@link - * Response} describing what should be sent. The framework renders the response after applying any + * Response} describing what should be sent. The server renders the response after applying any * registered {@link ResponseDecorator}s. Handlers may throw any {@link RuntimeException}; the * configured {@link ExceptionHandler} renders it. Handlers that need to surface an {@code * IOException} should wrap it as {@link java.io.UncheckedIOException}. */ @FunctionalInterface public interface RequestHandler { + /** + * Handles the request and returns the response to render. + * + * @param request the incoming request + * @return the response to send + */ Response handle(Request request); } diff --git a/src/main/java/com/retailsvc/http/RequestInterceptor.java b/src/main/java/com/retailsvc/http/RequestInterceptor.java index c855fbb..8aa5617 100644 --- a/src/main/java/com/retailsvc/http/RequestInterceptor.java +++ b/src/main/java/com/retailsvc/http/RequestInterceptor.java @@ -11,11 +11,23 @@ @FunctionalInterface public interface RequestInterceptor { + /** + * Runs around the next interceptor or handler in the chain. + * + * @param request the incoming request + * @param next the continuation to invoke the rest of the chain + * @return the response produced by the chain, possibly transformed + */ Response around(Request request, Continuation next); /** Continues the chain — calls the next interceptor, or the handler if this is the last one. */ @FunctionalInterface interface Continuation { + /** + * Invokes the next link in the chain. + * + * @return the response from the downstream interceptor or handler + */ Response proceed(); } } diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index f3e35b3..f1ba998 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -16,8 +16,8 @@ /** * The value returned by every {@link RequestHandler}. Carries status, optional body, optional - * content type, and headers. The framework renders it to the underlying {@code HttpExchange} after - * any registered {@link ResponseDecorator}s have transformed it. + * content type, and headers. The server renders it to the underlying {@code HttpExchange} after any + * registered {@link ResponseDecorator}s have transformed it. * *

Body handling: * @@ -29,28 +29,58 @@ *

  • Any other object → serialised by the {@link TypeMapper} registered for the response's * content type (default {@code application/json}). * + * + * @param status the HTTP status code to send back to the client (e.g. {@code 200}, {@code 404}). + * @param body the response payload, or {@code null} for a status-only response. May be a {@code + * byte[]} for verbatim bytes, a {@link BodyWriter} for streaming, or any object that the + * configured {@link TypeMapper} for {@link #contentType()} can serialise. + * @param contentType the {@code Content-Type} header value for the response, or {@code null} to + * default to {@code application/json} when a body is present. + * @param headers additional response headers to emit. Never {@code null} after canonicalisation; + * the compact constructor defensively copies the supplied map (or substitutes {@link Map#of()} + * if {@code null}) so the resulting {@code Response} is effectively immutable. */ public record Response(int status, Object body, String contentType, Map headers) { + /** + * Canonical constructor that normalises {@code headers}: a {@code null} map is replaced with an + * empty immutable map and any non-null map is defensively copied so subsequent mutations of the + * caller's map cannot leak into this {@code Response}. + */ public Response { headers = headers == null ? Map.of() : Map.copyOf(headers); } // -- one-shot, no-body -- - /** {@code 204 No Content} with no body. */ + /** + * {@code 204 No Content} with no body. + * + * @return a new {@code Response} with status {@code 204} and no body, content type, or headers. + */ public static Response empty() { return new Response(HTTP_NO_CONTENT, null, null, Map.of()); } - /** Given status, no body. Use for {@code 200 OK} no body, {@code 404}, {@code 405}, etc. */ + /** + * Given status, no body. Use for {@code 200 OK} no body, {@code 404}, {@code 405}, etc. + * + * @param status the HTTP status code to send. + * @return a new {@code Response} with the supplied status and no body, content type, or headers. + */ public static Response status(int status) { return new Response(status, null, null, Map.of()); } // -- one-shot, JSON body -- - /** {@code 200 OK} with {@code body} serialised as JSON. */ + /** + * {@code 200 OK} with {@code body} serialised as JSON. + * + * @param body the payload to serialise via the {@link TypeMapper} registered for {@code + * application/json}. + * @return a new {@code Response} with status {@code 200} and the supplied body. + */ public static Response ok(Object body) { return new Response(HTTP_OK, body, null, Map.of()); } @@ -58,44 +88,86 @@ public static Response ok(Object body) { /** * {@code 201 Created} with {@code body} serialised as JSON. Add a {@code Location} header for the * new resource via {@link #withHeader(String, String) withHeader("Location", uri)}. + * + * @param body the representation of the newly-created resource, serialised via the {@link + * TypeMapper} registered for {@code application/json}. + * @return a new {@code Response} with status {@code 201} and the supplied body. */ public static Response created(Object body) { return new Response(HTTP_CREATED, body, null, Map.of()); } - /** {@code 202 Accepted} with no body. Use for fire-and-forget async work. */ + /** + * {@code 202 Accepted} with no body. Use for fire-and-forget async work. + * + * @return a new {@code Response} with status {@code 202} and no body, content type, or headers. + */ public static Response accepted() { return new Response(HTTP_ACCEPTED, null, null, Map.of()); } - /** {@code 202 Accepted} with {@code body} serialised as JSON (typically a job/poll URL). */ + /** + * {@code 202 Accepted} with {@code body} serialised as JSON (typically a job/poll URL). + * + * @param body the payload describing where to poll for the async result, serialised via the + * {@link TypeMapper} registered for {@code application/json}. + * @return a new {@code Response} with status {@code 202} and the supplied body. + */ public static Response accepted(Object body) { return new Response(HTTP_ACCEPTED, body, null, Map.of()); } - /** {@code 404 Not Found} with no body. */ + /** + * {@code 404 Not Found} with no body. + * + * @return a new {@code Response} with status {@code 404} and no body, content type, or headers. + */ public static Response notFound() { return new Response(HTTP_NOT_FOUND, null, null, Map.of()); } - /** {@code 404 Not Found} with {@code body} serialised as JSON (e.g. a ProblemDetail). */ + /** + * {@code 404 Not Found} with {@code body} serialised as JSON (e.g. a ProblemDetail). + * + * @param body the payload to serialise, typically an RFC 7807 problem detail, via the {@link + * TypeMapper} registered for {@code application/json}. + * @return a new {@code Response} with status {@code 404} and the supplied body. + */ public static Response notFound(Object body) { return new Response(HTTP_NOT_FOUND, body, null, Map.of()); } - /** {@code 501 Not Implemented} with no body. */ + /** + * {@code 501 Not Implemented} with no body. + * + * @return a new {@code Response} with status {@code 501} and no body, content type, or headers. + */ public static Response notImplemented() { return new Response(HTTP_NOT_IMPLEMENTED, null, null, Map.of()); } - /** {@code status} with {@code body} serialised by the content-type's {@link TypeMapper}. */ + /** + * {@code status} with {@code body} serialised by the content-type's {@link TypeMapper}. + * + * @param status the HTTP status code to send. + * @param body the payload to serialise via the {@link TypeMapper} registered for the response's + * content type (defaults to {@code application/json}). + * @return a new {@code Response} with the supplied status and body. + */ public static Response of(int status, Object body) { return new Response(status, body, null, Map.of()); } // -- one-shot, text / raw bytes -- - /** {@code status} with {@code body} written as UTF-8 with {@code Content-Type: text/plain}. */ + /** + * {@code status} with {@code body} written as UTF-8 with {@code Content-Type: text/plain}. + * + * @param status the HTTP status code to send. + * @param body the text payload; encoded to bytes using {@link StandardCharsets#UTF_8}. + * @return a new {@code Response} carrying the UTF-8 encoded bytes with {@code Content-Type: + * text/plain; charset=UTF-8}. + */ public static Response text(int status, String body) { return new Response( status, body.getBytes(StandardCharsets.UTF_8), "text/plain; charset=UTF-8", Map.of()); @@ -103,6 +175,11 @@ public static Response text(int status, String body) { /** * {@code status} with pre-serialised {@code bytes} written verbatim under {@code contentType}. + * + * @param status the HTTP status code to send. + * @param bytes the pre-serialised payload to write verbatim to the response body. + * @param contentType the {@code Content-Type} header value to advertise for {@code bytes}. + * @return a new {@code Response} carrying {@code bytes} as the body under {@code contentType}. */ public static Response bytes(int status, byte[] bytes, String contentType) { return new Response(status, bytes, contentType, Map.of()); @@ -110,12 +187,34 @@ public static Response bytes(int status, byte[] bytes, String contentType) { // -- streaming -- - /** Streaming response with unknown length (chunked transfer encoding). */ + /** + * Streaming response with unknown length (chunked transfer encoding). + * + * @param status the HTTP status code to send. + * @param contentType the {@code Content-Type} header value to advertise for the streamed body. + * @param writer callback invoked with the response {@link OutputStream}; the body is written + * incrementally and flushed using chunked transfer encoding because the total length is not + * known up-front. + * @return a new {@code Response} that will stream its body via {@code writer} when rendered. + */ public static Response stream(int status, String contentType, StreamingBody writer) { return new Response(status, new BodyWriter.Chunked(writer::writeTo), contentType, Map.of()); } - /** Streaming response with a known content length. */ + /** + * Streaming response with a known content length. + * + * @param status the HTTP status code to send. + * @param length the exact number of bytes that {@code writer} will produce; sent as {@code + * Content-Length}. Must be non-negative. + * @param contentType the {@code Content-Type} header value to advertise for the streamed body. + * @param writer callback invoked with the response {@link OutputStream}; the body is written + * incrementally but the total length is advertised up-front so chunked transfer encoding is + * not used. + * @return a new {@code Response} that will stream exactly {@code length} bytes via {@code writer} + * when rendered. + * @throws IllegalArgumentException if {@code length} is negative. + */ public static Response stream(int status, long length, String contentType, StreamingBody writer) { if (length < 0) { throw new IllegalArgumentException("length must be non-negative"); @@ -126,20 +225,48 @@ public static Response stream(int status, long length, String contentType, Strea // -- non-destructive mutators -- + /** + * Returns a copy of this response with the status code replaced. + * + * @param newStatus the HTTP status code to use in the returned {@code Response}. + * @return a new {@code Response} identical to this one except for {@link #status()}. + */ public Response withStatus(int newStatus) { return new Response(newStatus, body, contentType, headers); } + /** + * Returns a copy of this response with the content type replaced. + * + * @param newContentType the {@code Content-Type} header value to use, or {@code null} to fall + * back to the library default ({@code application/json}) when serialising the body. + * @return a new {@code Response} identical to this one except for {@link #contentType()}. + */ public Response withContentType(String newContentType) { return new Response(status, body, newContentType, headers); } + /** + * Returns a copy of this response with an additional (or replaced) header. Existing headers are + * preserved; if {@code name} already exists its value is overwritten. + * + * @param name the header name to set. + * @param value the header value to set. + * @return a new {@code Response} with the merged headers. + */ public Response withHeader(String name, String value) { LinkedHashMap merged = new LinkedHashMap<>(headers); merged.put(name, value); return new Response(status, body, contentType, merged); } + /** + * Returns a copy of this response with the supplied headers merged on top of the existing ones. + * Entries in {@code additional} overwrite any existing header with the same name. + * + * @param additional the headers to merge into the returned {@code Response}. + * @return a new {@code Response} with the merged headers. + */ public Response withHeaders(Map additional) { LinkedHashMap merged = new LinkedHashMap<>(headers); merged.putAll(additional); @@ -149,6 +276,13 @@ public Response withHeaders(Map additional) { /** Writer signature for {@link #stream(int, String, StreamingBody)}. */ @FunctionalInterface public interface StreamingBody { + /** + * Writes the streamed response body to {@code out}. Implementations should write all bytes and + * may flush as needed; the server closes the stream once this method returns. + * + * @param out the response output stream to write to. + * @throws IOException if writing to {@code out} fails. + */ void writeTo(OutputStream out) throws IOException; } } diff --git a/src/main/java/com/retailsvc/http/ResponseDecorator.java b/src/main/java/com/retailsvc/http/ResponseDecorator.java index 5603a98..ab796b0 100644 --- a/src/main/java/com/retailsvc/http/ResponseDecorator.java +++ b/src/main/java/com/retailsvc/http/ResponseDecorator.java @@ -1,7 +1,7 @@ package com.retailsvc.http; /** - * Transforms the {@link Response} returned by a handler before the framework renders it. Decorators + * Transforms the {@link Response} returned by a handler before the server renders it. Decorators * run in registration order; the result of each is fed to the next. Use for cross-cutting headers * (correlation id, tenant id, server identifier) or any other uniform response shaping. * @@ -11,5 +11,12 @@ */ @FunctionalInterface public interface ResponseDecorator { + /** + * Transforms the handler's response before rendering. + * + * @param request the originating request + * @param response the response produced so far + * @return the transformed response to pass on + */ Response decorate(Request request, Response response); } diff --git a/src/main/java/com/retailsvc/http/SchemeValidator.java b/src/main/java/com/retailsvc/http/SchemeValidator.java index 00a6d13..0051f8b 100644 --- a/src/main/java/com/retailsvc/http/SchemeValidator.java +++ b/src/main/java/com/retailsvc/http/SchemeValidator.java @@ -8,5 +8,12 @@ */ @FunctionalInterface public interface SchemeValidator { + /** + * Validates the extracted credential for the given request. + * + * @param request the incoming request + * @param credential the credential extracted from the request + * @return the authenticated principal, or {@link Optional#empty()} to deny + */ Optional validate(Request request, Credential credential); } diff --git a/src/main/java/com/retailsvc/http/TypeMapper.java b/src/main/java/com/retailsvc/http/TypeMapper.java index 8af27d0..a60c2a9 100644 --- a/src/main/java/com/retailsvc/http/TypeMapper.java +++ b/src/main/java/com/retailsvc/http/TypeMapper.java @@ -10,12 +10,22 @@ public interface TypeMapper { /** + * Parses the raw request body bytes into a Java object suitable for OpenAPI schema validation + * (typically {@code Map} / {@code List} / String / Number / Boolean / + * null for JSON). + * * @param body raw request body bytes * @param contentTypeHeader the full raw {@code Content-Type} header, used for charset and other * parameters (the JSON mapper ignores it) + * @return the parsed value, or {@code null} for an empty body */ Object readFrom(byte[] body, String contentTypeHeader); - /** Serializes {@code value} to bytes suitable for writing as the response body. */ + /** + * Serializes {@code value} to bytes suitable for writing as the response body. + * + * @param value the value to serialise; the accepted type is type-mapper-dependent + * @return the serialised bytes ready to write to the response output stream + */ byte[] writeTo(Object value); } diff --git a/src/main/java/com/retailsvc/http/TypedTypeMapper.java b/src/main/java/com/retailsvc/http/TypedTypeMapper.java index 78126c0..1b613fa 100644 --- a/src/main/java/com/retailsvc/http/TypedTypeMapper.java +++ b/src/main/java/com/retailsvc/http/TypedTypeMapper.java @@ -2,7 +2,7 @@ /** * Optional capability for {@link TypeMapper}s that can deserialise a request body directly into a - * caller-supplied target type. The framework uses this when handlers call {@link + * caller-supplied target type. The library uses this when handlers call {@link * Request#asPojo(Class)}; mappers that cannot meaningfully honour a target type (e.g. the built-in * form / text mappers) should not implement this interface. * @@ -17,6 +17,8 @@ public interface TypedTypeMapper extends TypeMapper { * @param body raw request body bytes * @param contentTypeHeader the full raw {@code Content-Type} header (for charset / params) * @param type the target type + * @param the target type + * @return the deserialised instance */ T readAs(byte[] body, String contentTypeHeader, Class type); } diff --git a/src/main/java/com/retailsvc/http/ValidationException.java b/src/main/java/com/retailsvc/http/ValidationException.java index 6bbd505..39eea91 100644 --- a/src/main/java/com/retailsvc/http/ValidationException.java +++ b/src/main/java/com/retailsvc/http/ValidationException.java @@ -3,6 +3,7 @@ import com.retailsvc.http.validate.ValidationError; import java.util.concurrent.atomic.AtomicLong; +/** Runtime exception raised when a request fails OpenAPI schema validation. */ public final class ValidationException extends RuntimeException { /** * Counts every {@code ValidationException} ever constructed. Used to assert that the validator @@ -13,12 +14,22 @@ public final class ValidationException extends RuntimeException { private final transient ValidationError error; + /** + * Creates a new validation exception wrapping the given error. + * + * @param error the validation error that triggered this exception + */ public ValidationException(ValidationError error) { super(error.pointer() + " [" + error.keyword() + "] " + error.message()); this.error = error; CONSTRUCTIONS.incrementAndGet(); } + /** + * Returns the underlying validation error. + * + * @return the validation error wrapped by this exception + */ public ValidationError error() { return error; } diff --git a/src/main/java/com/retailsvc/http/internal/BodyWriter.java b/src/main/java/com/retailsvc/http/internal/BodyWriter.java index ca6c7f7..83dad5e 100644 --- a/src/main/java/com/retailsvc/http/internal/BodyWriter.java +++ b/src/main/java/com/retailsvc/http/internal/BodyWriter.java @@ -9,9 +9,23 @@ */ public sealed interface BodyWriter permits BodyWriter.Sized, BodyWriter.Chunked { + /** + * Writes the body bytes to the exchange output stream. + * + *

    Declares {@link IOException} so the renderer can let it propagate up to the server's + * exception filter rather than swallowing it at the body-writing layer. + * + * @param out the exchange output stream to write the response body to + * @throws IOException if writing to the output stream fails + */ void writeTo(OutputStream out) throws IOException; - /** Known {@code Content-Length}. */ + /** + * Known {@code Content-Length}. + * + * @param length the exact {@code Content-Length} to set on the response + * @param writer writes the body bytes to the exchange output stream + */ record Sized(long length, IOConsumer writer) implements BodyWriter { @Override public void writeTo(OutputStream out) throws IOException { @@ -19,7 +33,14 @@ public void writeTo(OutputStream out) throws IOException { } } - /** Unknown length — chunked transfer encoding. */ + /** + * Unknown length — chunked transfer encoding. + * + *

    The renderer uses chunked transfer encoding because the body length is unknown ahead of + * time. + * + * @param writer writes the body bytes to the exchange output stream + */ record Chunked(IOConsumer writer) implements BodyWriter { @Override public void writeTo(OutputStream out) throws IOException { @@ -30,6 +51,12 @@ public void writeTo(OutputStream out) throws IOException { /** {@code Consumer} that is allowed to throw {@link IOException}. */ @FunctionalInterface interface IOConsumer { + /** + * Single write callback that streams bytes to the given output stream. + * + * @param out the exchange output stream to write to + * @throws IOException if writing to the output stream fails + */ void accept(OutputStream out) throws IOException; } } diff --git a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java index d0858ca..4775d93 100644 --- a/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java +++ b/src/main/java/com/retailsvc/http/internal/ContentTypeHeader.java @@ -11,6 +11,10 @@ private ContentTypeHeader() {} /** * Returns the bare media type, stripping parameters and lower-casing for case-insensitive * matching (RFC 9110 / 2045). {@code null} → {@code application/json}. + * + * @param header raw {@code Content-Type} header value (nullable; missing header is treated as + * {@code application/json}). + * @return the bare media type lower-cased with parameters stripped. */ public static String mediaType(String header) { if (header == null) { @@ -21,7 +25,13 @@ public static String mediaType(String header) { return bare.trim().toLowerCase(Locale.ROOT); } - /** Returns the named parameter value (e.g. {@code charset}), or empty if absent. */ + /** + * Returns the named parameter value (e.g. {@code charset}), or empty if absent. + * + * @param header raw {@code Content-Type} header value (nullable returns empty). + * @param name the parameter name to look up (case-insensitive, e.g. {@code charset}). + * @return the parameter value (unquoted if quoted), or empty if the header has no such parameter. + */ public static Optional parameter(String header, String name) { if (header == null) { return Optional.empty(); diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index aeb9bee..c1baba2 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -12,9 +12,36 @@ import java.util.List; import java.util.Map; +/** + * Final {@link HttpHandler} that dispatches a fully-prepared {@link Request} (bound via {@link + * #CURRENT}) to the user-supplied {@link RequestHandler} for its {@code operationId}, wraps the + * chain in the registered {@link RequestInterceptor}s, applies the registered {@link + * ResponseDecorator}s to the result, then renders the final {@link Response} via the shared {@link + * ResponseRenderer}. + * + *

    If no handler is registered for the resolved {@code operationId}, this dispatcher throws + * {@link MissingOperationHandlerException}. + */ public final class DispatchHandler implements HttpHandler { + /** + * Thread-confined {@link ScopedValue} that binds the current {@link Request} for the lifetime of + * dispatch. + * + *

    Read by request interceptors and after-response hooks that need access to the request + * without it being threaded through their signatures. The binding is not propagated across + * executor boundaries — code that hands work off to another thread must capture the value + * explicitly before doing so. + */ public static final ScopedValue CURRENT = ScopedValue.newInstance(); + + /** + * {@link com.sun.net.httpserver.HttpExchange} attribute key under which the rendered {@link + * Response} is stashed for downstream filters. + * + *

    Consumed by the access-log filter and after-response hooks that need to inspect the response + * produced by this dispatcher. + */ public static final String RESPONSE_ATTR = "com.retailsvc.http.response"; private final Map handlers; @@ -22,6 +49,16 @@ public final class DispatchHandler implements HttpHandler { private final List decorators; private final ResponseRenderer renderer; + /** + * Creates a new dispatcher. + * + * @param handlers map of {@code operationId} to {@link RequestHandler} (defensively copied) + * @param interceptors registered {@link RequestInterceptor}s in registration order (defensively + * copied) + * @param decorators registered {@link ResponseDecorator}s in registration order (defensively + * copied) + * @param renderer the shared {@link ResponseRenderer} used to write responses to the exchange + */ public DispatchHandler( Map handlers, List interceptors, diff --git a/src/main/java/com/retailsvc/http/internal/ExceptionFilter.java b/src/main/java/com/retailsvc/http/internal/ExceptionFilter.java index 649a14e..1ed781a 100644 --- a/src/main/java/com/retailsvc/http/internal/ExceptionFilter.java +++ b/src/main/java/com/retailsvc/http/internal/ExceptionFilter.java @@ -6,11 +6,25 @@ import com.sun.net.httpserver.HttpExchange; import java.io.IOException; +/** + * Outermost filter in the chain. Catches {@link RuntimeException} and {@link IOException} thrown by + * any downstream filter or handler, maps the throwable to a {@link Response} via the user-supplied + * {@link ExceptionHandler}, and renders it via the shared {@link ResponseRenderer}. + * + *

    Runs outside any interceptor {@link java.lang.ScopedValue} bindings — those are torn down as + * the exception unwinds. + */ public final class ExceptionFilter extends Filter { private final ExceptionHandler handler; private final ResponseRenderer renderer; + /** + * Creates a new exception filter. + * + * @param handler user-supplied exception-to-response mapper + * @param renderer the shared response renderer used to write the error response + */ public ExceptionFilter(ExceptionHandler handler, ResponseRenderer renderer) { this.handler = handler; this.renderer = renderer; diff --git a/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java b/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java index 95d496d..545b2dc 100644 --- a/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java +++ b/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java @@ -22,6 +22,12 @@ public final class ExtraRouteAdapter implements HttpHandler { private final RequestHandler handler; private final ResponseRenderer renderer; + /** + * Create an adapter wrapping a user-supplied extra-route handler. + * + * @param handler the user-supplied handler for this extra (out-of-spec) route + * @param renderer the shared response renderer + */ public ExtraRouteAdapter(RequestHandler handler, ResponseRenderer renderer) { this.handler = handler; this.renderer = renderer; diff --git a/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java b/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java index c5fea5b..db292e3 100644 --- a/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java +++ b/src/main/java/com/retailsvc/http/internal/FormTypeMapper.java @@ -11,6 +11,13 @@ public final class FormTypeMapper implements TypeMapper { private final FormUrlEncodedParser parser = new FormUrlEncodedParser(); + /** + * Creates a form-urlencoded type mapper. The mapper is stateless beyond a lazily-shared parser. + */ + public FormTypeMapper() { + // State is held by the final parser field above. + } + @Override public Object readFrom(byte[] body, String contentTypeHeader) { return parser.parse(body, contentTypeHeader); diff --git a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java index f3811a7..592dddd 100644 --- a/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java +++ b/src/main/java/com/retailsvc/http/internal/FormUrlEncodedParser.java @@ -15,7 +15,19 @@ /** Parses an {@code application/x-www-form-urlencoded} request body. */ public final class FormUrlEncodedParser { - /** Parses the body to a {@code Map} ({@code String} or {@code List}). */ + /** Creates a new parser. */ + public FormUrlEncodedParser() { + // Stateless; nothing to initialise. + } + + /** + * Parses the body to a {@code Map} ({@code String} or {@code List}). + * + * @param body raw request body bytes + * @param contentTypeHeader the request {@code Content-Type} header (used for the charset + * parameter); may be {@code null} + * @return decoded form fields preserving insertion order + */ public Map parse(byte[] body, String contentTypeHeader) { Charset charset = resolveCharset(contentTypeHeader); if (body.length == 0) { diff --git a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java index cec7e45..c38f0ea 100644 --- a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java @@ -14,6 +14,13 @@ public final class HealthRenderer { private HealthRenderer() {} + /** + * Renders the health outcome as a JSON document. + * + * @param up overall outcome ({@code true} = Up, {@code false} = Down) + * @param dependencies dependency outcomes to include + * @return a JSON string with {@code outcome} and {@code dependencies} fields + */ public static String renderJson(boolean up, List dependencies) { StringBuilder sb = new StringBuilder(INITIAL_CAPACITY); sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":["); diff --git a/src/main/java/com/retailsvc/http/internal/NotFoundHandler.java b/src/main/java/com/retailsvc/http/internal/NotFoundHandler.java index c4a31e5..ca3a116 100644 --- a/src/main/java/com/retailsvc/http/internal/NotFoundHandler.java +++ b/src/main/java/com/retailsvc/http/internal/NotFoundHandler.java @@ -6,9 +6,14 @@ import com.sun.net.httpserver.HttpHandler; import java.io.IOException; -/** Returns 404 with no body. Used for the framework's catch-all {@code /} context. */ +/** Returns 404 with no body. Used for the server's catch-all {@code /} context. */ public final class NotFoundHandler implements HttpHandler { + /** Creates a new handler. */ + public NotFoundHandler() { + // Stateless; nothing to initialise. + } + @Override public void handle(HttpExchange exchange) throws IOException { try (exchange) { diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java index b34135e..a764ea5 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java @@ -8,17 +8,50 @@ * Carrier for an RFC 7807 problem+json document. Serialized by the registered JSON {@code * TypeMapper}; the wire shape and field-order are whatever the configured mapper produces — title * is advisory per RFC 7807 since {@code type} is always {@code about:blank}. + * + * @param type URI reference that identifies the problem type. This library always emits {@code + * about:blank}, which per RFC 7807 means {@code title} carries the advisory description of the + * problem. + * @param title short, human-readable summary of the problem type (e.g. {@code "Bad Request"}). + * Advisory only when {@code type} is {@code about:blank}, but still surfaced to clients. + * @param status HTTP status code generated by the origin server for this occurrence of the problem; + * mirrors the status written on the wire. + * @param detail human-readable explanation specific to this occurrence of the problem (e.g. the + * concrete validation message for the offending request). + * @param pointer Hii Retail extension to RFC 7807: a JSON Pointer ({@code RFC 6901}) into the + * request body identifying the offending property. {@code null} when no pointer applies (e.g. + * header / query / generic errors). + * @param keyword Hii Retail extension to RFC 7807: the JSON Schema (or OpenAPI) validation keyword + * that failed, such as {@code "required"}, {@code "type"} or {@code "pattern"}. {@code null} + * when the error did not originate from schema validation. */ public record ProblemDetail( String type, String title, int status, String detail, String pointer, String keyword) { private static final String DEFAULT_TYPE = "about:blank"; + /** + * Builds a {@code ProblemDetail} for a schema/parameter validation failure. Uses {@code + * about:blank} as the type, {@code "Bad Request"} as the title, status {@code 400}, and copies + * the message, JSON Pointer and failing keyword from the {@link ValidationError}. + * + * @param e the validation error produced by the request validator + * @return a 400 problem document describing the validation failure + */ public static ProblemDetail forValidation(ValidationError e) { return new ProblemDetail( DEFAULT_TYPE, "Bad Request", 400, e.message(), e.pointer(), e.keyword()); } + /** + * Builds a {@code ProblemDetail} from a {@link BadRequestException} thrown by application code. + * The status is taken from the exception, the title is resolved from a standard HTTP reason + * phrase table (falling back to {@code "Bad Request"}), and the optional pointer/keyword are + * propagated as Hii Retail extensions when present. + * + * @param e the bad-request exception raised by the handler chain + * @return a problem document mirroring the exception's status, message and optional metadata + */ public static ProblemDetail forBadRequest(BadRequestException e) { return new ProblemDetail( DEFAULT_TYPE, diff --git a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java index f9283dc..22e61a8 100644 --- a/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java +++ b/src/main/java/com/retailsvc/http/internal/RequestPreparationFilter.java @@ -29,6 +29,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Filter that reads the request body, resolves the OpenAPI operation, validates parameters and + * body, and exposes the parsed {@link Request} via {@link DispatchHandler#CURRENT} for downstream + * filters and the dispatch handler. + */ public final class RequestPreparationFilter extends Filter { private static final Logger LOG = LoggerFactory.getLogger(RequestPreparationFilter.class); @@ -42,6 +47,17 @@ public final class RequestPreparationFilter extends Filter { private final ResponseRenderer renderer; private final List afterHooks; + /** + * Creates a new request preparation filter. + * + * @param spec the parsed OpenAPI spec + * @param router routes requests to their {@link Operation} + * @param validator validates parameters and request bodies + * @param bodyMappers media-type to {@link TypeMapper} registry for parsing request bodies + * @param exceptionHandler handles exceptions thrown during request preparation and dispatch + * @param renderer renders the {@link Response} produced by the exception handler + * @param afterHooks hooks invoked after the response has been produced + */ @SuppressWarnings("java:S107") public RequestPreparationFilter( Spec spec, diff --git a/src/main/java/com/retailsvc/http/internal/ResourceSource.java b/src/main/java/com/retailsvc/http/internal/ResourceSource.java index 3e06f23..d02826b 100644 --- a/src/main/java/com/retailsvc/http/internal/ResourceSource.java +++ b/src/main/java/com/retailsvc/http/internal/ResourceSource.java @@ -15,14 +15,42 @@ */ public sealed interface ResourceSource { + /** + * Returns the resource's byte length, resolved at construction. + * + * @return byte length of the resource + */ long length(); + /** + * Returns the media type inferred from the resource's file extension. + * + * @return media type string + */ String contentType(); + /** + * Returns a human-readable description used in error messages and logs. + * + * @return description of the resource + */ String describe(); + /** + * Opens a fresh {@link InputStream} for reading the resource bytes. + * + * @return a new input stream the caller must close + * @throws IOException if the resource cannot be opened + */ InputStream open() throws IOException; + /** + * Creates a {@code ResourceSource} backed by a classpath resource. + * + * @param classpathResource absolute classpath path (must start with {@code /}) + * @return a fail-fast handle to the resource + * @throws IllegalArgumentException if the resource is missing or unreadable + */ static ResourceSource ofClasspath(String classpathResource) { Objects.requireNonNull(classpathResource, "classpathResource"); long length; @@ -38,6 +66,14 @@ static ResourceSource ofClasspath(String classpathResource) { return new Classpath(classpathResource, length, contentTypeFor(classpathResource)); } + /** + * Creates a {@code ResourceSource} backed by a filesystem file. + * + * @param file path to a regular file + * @return a fail-fast handle to the file + * @throws IllegalArgumentException if {@code file} is not a regular file or its size cannot be + * read + */ static ResourceSource ofFile(Path file) { Objects.requireNonNull(file, "file"); if (!Files.isRegularFile(file)) { @@ -52,6 +88,13 @@ static ResourceSource ofFile(Path file) { return new File(file, length, contentTypeFor(file.getFileName().toString())); } + /** + * Maps a file extension to a Content-Type string. + * + * @param path file name or path; only the extension is inspected (case-insensitive) + * @return the inferred media type, or {@code application/octet-stream} if the extension is + * unknown + */ static String contentTypeFor(String path) { String lower = path.toLowerCase(Locale.ROOT); if (lower.endsWith(".json")) { @@ -75,6 +118,13 @@ static String contentTypeFor(String path) { return "application/octet-stream"; } + /** + * Classpath-backed {@link ResourceSource}. + * + * @param path absolute classpath path (must start with {@code /}) + * @param length pre-resolved byte length + * @param contentType media type inferred from the path's extension + */ record Classpath(String path, long length, String contentType) implements ResourceSource { @Override public InputStream open() throws IOException { @@ -91,6 +141,13 @@ public String describe() { } } + /** + * Filesystem-backed {@link ResourceSource}. + * + * @param path file path + * @param length pre-resolved byte length + * @param contentType media type inferred from the file's extension + */ record File(Path path, long length, String contentType) implements ResourceSource { @Override public InputStream open() throws IOException { diff --git a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java index 204935f..ca82fef 100644 --- a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java @@ -18,10 +18,23 @@ public final class ResponseRenderer { private final Map mappers; + /** + * Creates a new renderer. + * + * @param mappers media-type to {@link TypeMapper} registry used to serialise response bodies + */ public ResponseRenderer(Map mappers) { this.mappers = Map.copyOf(mappers); } + /** + * Writes the response to the exchange, serialising the body using the registered mapper for the + * response content type. + * + * @param exchange the HTTP exchange to write to + * @param response the response to render + * @throws IOException if writing to the exchange fails + */ public void render(HttpExchange exchange, Response response) throws IOException { try (exchange) { Headers headers = exchange.getResponseHeaders(); diff --git a/src/main/java/com/retailsvc/http/internal/Router.java b/src/main/java/com/retailsvc/http/internal/Router.java index 7c44796..70ba164 100644 --- a/src/main/java/com/retailsvc/http/internal/Router.java +++ b/src/main/java/com/retailsvc/http/internal/Router.java @@ -11,13 +11,25 @@ import java.util.Optional; import java.util.Set; +/** Resolves an OpenAPI {@link Operation} from an HTTP method and request path. */ public final class Router { + /** + * Successful route match. + * + * @param operation the matched OpenAPI operation + * @param pathParameters extracted path-template variables (empty for exact matches) + */ public record Match(Operation operation, Map pathParameters) {} private final Map> exact = new EnumMap<>(HttpMethod.class); private final Map> templated = new EnumMap<>(HttpMethod.class); + /** + * Indexes operations by method, splitting exact paths from templated paths for fast lookup. + * + * @param operations the operations declared in the OpenAPI spec + */ public Router(List operations) { for (HttpMethod m : HttpMethod.values()) { exact.put(m, new LinkedHashMap<>()); @@ -32,6 +44,13 @@ public Router(List operations) { } } + /** + * Finds the operation matching the given method and path. + * + * @param method the HTTP method + * @param path the request path with the base path stripped + * @return the matching operation and any extracted path parameters, or empty if none matches + */ public Optional match(HttpMethod method, String path) { Operation hit = exact.get(method).get(path); if (hit != null) { @@ -46,6 +65,13 @@ public Optional match(HttpMethod method, String path) { return Optional.empty(); } + /** + * Returns the set of HTTP methods that have an operation for the given path. Used to build the + * {@code Allow} header on 405 responses. + * + * @param path the request path with the base path stripped + * @return the methods declared for that path; empty if the path is unknown + */ public Set allowedMethods(String path) { EnumSet out = EnumSet.noneOf(HttpMethod.class); for (HttpMethod m : HttpMethod.values()) { diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index 22bd1d7..a3d7ac0 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -22,6 +22,11 @@ import java.util.Objects; import java.util.Optional; +/** + * Filter that enforces OpenAPI security requirements. Extracts credentials from the exchange, + * delegates validation to a per-scheme {@link SchemeValidator}, and renders an RFC 7807 problem + * response with appropriate {@code WWW-Authenticate} challenges on rejection. + */ public final class SecurityFilter extends Filter { private final Map operationsById; @@ -31,6 +36,17 @@ public final class SecurityFilter extends Filter { private final boolean externalAuth; private final TypeMapper jsonMapper; + /** + * Creates a new security filter. + * + * @param operationsById all operations indexed by {@code operationId} + * @param schemes named security schemes declared in the OpenAPI spec + * @param rootSecurity the spec-level security requirements applied when an operation does not + * declare its own + * @param validators per-scheme credential validators keyed by scheme name + * @param externalAuth if {@code true}, skip enforcement and delegate to a fronting authenticator + * @param jsonMapper mapper used to serialise the problem-details response body + */ public SecurityFilter( Map operationsById, Map schemes, diff --git a/src/main/java/com/retailsvc/http/internal/TextPlainParser.java b/src/main/java/com/retailsvc/http/internal/TextPlainParser.java index bcf5b6e..7d5e4fe 100644 --- a/src/main/java/com/retailsvc/http/internal/TextPlainParser.java +++ b/src/main/java/com/retailsvc/http/internal/TextPlainParser.java @@ -8,6 +8,18 @@ /** Decodes a text/plain request body using the charset declared on {@code Content-Type}. */ public final class TextPlainParser { + /** Creates a new parser. */ + public TextPlainParser() { + // Stateless; nothing to initialise. + } + + /** + * Decodes the body as text using the charset declared on {@code Content-Type} (default UTF-8). + * + * @param body raw request body bytes + * @param contentTypeHeader the request {@code Content-Type} header; may be {@code null} + * @return the decoded string + */ public String parse(byte[] body, String contentTypeHeader) { Charset charset = resolveCharset(contentTypeHeader); return new String(body, charset); diff --git a/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java index a6b58d6..c9c154e 100644 --- a/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java +++ b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java @@ -11,6 +11,11 @@ */ public final class TextTypeMapper implements TypeMapper { + /** Creates a new mapper. */ + public TextTypeMapper() { + // State is held by the final parser field below. + } + private final TextPlainParser parser = new TextPlainParser(); @Override diff --git a/src/main/java/com/retailsvc/http/internal/ValueCoercion.java b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java index 1b98491..3688804 100644 --- a/src/main/java/com/retailsvc/http/internal/ValueCoercion.java +++ b/src/main/java/com/retailsvc/http/internal/ValueCoercion.java @@ -12,6 +12,15 @@ public final class ValueCoercion { private ValueCoercion() {} + /** + * Coerces a wire-format string to the Java type implied by the target schema. + * + * @param raw the raw string value (e.g. a query parameter or form field) + * @param schema the target schema + * @param pointer JSON pointer used in validation errors + * @return the coerced value ({@code Long}, {@code Double}, {@code Boolean}, or the raw string) + * @throws ValidationException if the value cannot be coerced to the schema type + */ public static Object coerce(String raw, Schema schema, String pointer) { return switch (schema) { case IntegerSchema _ -> { diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java index 1deabdd..9c4c490 100644 --- a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java +++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java @@ -47,15 +47,25 @@ public final class GsonJsonMapper implements TypedTypeMapper { private final Gson gson; + /** Creates a mapper backed by a default {@link Gson} instance with JSR-310 adapters. */ public GsonJsonMapper() { this(defaultGson()); } + /** + * Creates a mapper backed by the supplied {@link Gson} instance. + * + * @param gson the Gson instance to delegate to; must not be {@code null} + */ public GsonJsonMapper(Gson gson) { this.gson = Objects.requireNonNull(gson, "gson must not be null"); } - /** Returns the wrapped {@link Gson} instance. */ + /** + * Returns the wrapped {@link Gson} instance. + * + * @return the wrapped {@link Gson} instance + */ public Gson gson() { return gson; } diff --git a/src/main/java/com/retailsvc/http/spec/HttpMethod.java b/src/main/java/com/retailsvc/http/spec/HttpMethod.java index 920b48f..ea97652 100644 --- a/src/main/java/com/retailsvc/http/spec/HttpMethod.java +++ b/src/main/java/com/retailsvc/http/spec/HttpMethod.java @@ -2,17 +2,33 @@ import java.util.Locale; +/** HTTP request methods supported by OpenAPI path operations. */ public enum HttpMethod { + /** HTTP {@code GET} method. */ GET, + /** HTTP {@code POST} method. */ POST, + /** HTTP {@code PUT} method. */ PUT, + /** HTTP {@code DELETE} method. */ DELETE, + /** HTTP {@code PATCH} method. */ PATCH, + /** HTTP {@code HEAD} method. */ HEAD, + /** HTTP {@code OPTIONS} method. */ OPTIONS, + /** HTTP {@code TRACE} method. */ TRACE, + /** HTTP {@code CONNECT} method. */ CONNECT; + /** + * Parses the given string into an {@link HttpMethod}, case-insensitively. + * + * @param s the method name + * @return the matching {@link HttpMethod} + */ public static HttpMethod parse(String s) { return HttpMethod.valueOf(s.toUpperCase(Locale.ROOT)); } diff --git a/src/main/java/com/retailsvc/http/spec/Info.java b/src/main/java/com/retailsvc/http/spec/Info.java index ed97d90..27b42dc 100644 --- a/src/main/java/com/retailsvc/http/spec/Info.java +++ b/src/main/java/com/retailsvc/http/spec/Info.java @@ -2,4 +2,11 @@ import java.util.Map; +/** + * OpenAPI {@code info} object describing the API's identity. + * + * @param title human-readable API title + * @param version API version string + * @param extensions OpenAPI {@code x-*} specification extensions + */ public record Info(String title, String version, Map extensions) {} diff --git a/src/main/java/com/retailsvc/http/spec/MediaType.java b/src/main/java/com/retailsvc/http/spec/MediaType.java index 4944aea..e207ad5 100644 --- a/src/main/java/com/retailsvc/http/spec/MediaType.java +++ b/src/main/java/com/retailsvc/http/spec/MediaType.java @@ -2,4 +2,9 @@ import com.retailsvc.http.spec.schema.Schema; +/** + * OpenAPI {@code mediaType} object binding a content type to a payload schema. + * + * @param schema schema describing the payload for this media type + */ public record MediaType(Schema schema) {} diff --git a/src/main/java/com/retailsvc/http/spec/Operation.java b/src/main/java/com/retailsvc/http/spec/Operation.java index 912c802..d848463 100644 --- a/src/main/java/com/retailsvc/http/spec/Operation.java +++ b/src/main/java/com/retailsvc/http/spec/Operation.java @@ -5,6 +5,19 @@ import java.util.Map; import java.util.Optional; +/** + * Resolved OpenAPI operation: a single (method, path) pair with its validation metadata, used by + * the router and dispatcher to route requests to user-supplied handlers. + * + * @param operationId unique OpenAPI {@code operationId} identifying this operation + * @param method HTTP method for this operation + * @param path path template (possibly containing {@code {var}} placeholders) + * @param requestBody request body definition, if any + * @param parameters declared path, query, header and cookie parameters + * @param responses response definitions keyed by status code (or {@code default}) + * @param extensions OpenAPI {@code x-*} specification extensions + * @param security security requirements that override the document-level security, if present + */ public record Operation( String operationId, HttpMethod method, diff --git a/src/main/java/com/retailsvc/http/spec/Parameter.java b/src/main/java/com/retailsvc/http/spec/Parameter.java index 3fba8c9..7d71bd6 100644 --- a/src/main/java/com/retailsvc/http/spec/Parameter.java +++ b/src/main/java/com/retailsvc/http/spec/Parameter.java @@ -3,16 +3,38 @@ import com.retailsvc.http.spec.schema.Schema; import java.util.Locale; +/** + * OpenAPI {@code parameter} object describing a single path, query, header or cookie parameter. + * + * @param name parameter name as it appears on the wire + * @param in where the parameter is carried in the request + * @param required whether the parameter must be present + * @param schema schema used to validate the parameter value + * @param pointer JSON Pointer-style location used in validation error messages + */ public record Parameter(String name, Location in, boolean required, Schema schema, String pointer) { + /** + * Convenience constructor that derives {@code pointer} from {@code in} and {@code name}. + * + * @param name parameter name as it appears on the wire + * @param in where the parameter is carried in the request + * @param required whether the parameter must be present + * @param schema schema used to validate the parameter value + */ public Parameter(String name, Location in, boolean required, Schema schema) { this(name, in, required, schema, "/" + in.name().toLowerCase(Locale.ROOT) + "/" + name); } + /** Where in an HTTP request a {@link Parameter} is carried. */ public enum Location { + /** Path parameter, substituted into a templated URL segment. */ PATH, + /** Query string parameter. */ QUERY, + /** Request header. */ HEADER, + /** Cookie value. */ COOKIE } } diff --git a/src/main/java/com/retailsvc/http/spec/PathTemplate.java b/src/main/java/com/retailsvc/http/spec/PathTemplate.java index 1430e15..88c7138 100644 --- a/src/main/java/com/retailsvc/http/spec/PathTemplate.java +++ b/src/main/java/com/retailsvc/http/spec/PathTemplate.java @@ -8,10 +8,23 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * A compiled OpenAPI path template that matches request paths and extracts named path parameters. + * + * @param raw the original template string, e.g. {@code /pets/{id}} + * @param compiled the regex compiled from {@code raw} + * @param parameterNames the parameter names in the order they appear in {@code raw} + */ public record PathTemplate(String raw, Pattern compiled, List parameterNames) { private static final Pattern TOKEN = Pattern.compile("\\{([^/}]+)}"); + /** + * Compiles a path template such as {@code /pets/{id}} into a {@link PathTemplate}. + * + * @param template the OpenAPI path template + * @return the compiled template + */ public static PathTemplate compile(String template) { StringBuilder regex = new StringBuilder("^"); List names = new ArrayList<>(); @@ -28,6 +41,13 @@ public static PathTemplate compile(String template) { return new PathTemplate(template, Pattern.compile(regex.toString()), List.copyOf(names)); } + /** + * Matches a concrete request path against this template. + * + * @param path the request path to match + * @return the extracted parameter values keyed by name, or {@link Optional#empty()} if {@code + * path} does not match this template + */ public Optional> match(String path) { Matcher m = compiled.matcher(path); if (!m.matches()) { diff --git a/src/main/java/com/retailsvc/http/spec/RequestBody.java b/src/main/java/com/retailsvc/http/spec/RequestBody.java index eae9f28..40d4581 100644 --- a/src/main/java/com/retailsvc/http/spec/RequestBody.java +++ b/src/main/java/com/retailsvc/http/spec/RequestBody.java @@ -2,4 +2,10 @@ import java.util.Map; +/** + * OpenAPI {@code requestBody} object describing the payload an operation accepts. + * + * @param required whether the request body must be present + * @param content supported media types keyed by content-type string + */ public record RequestBody(boolean required, Map content) {} diff --git a/src/main/java/com/retailsvc/http/spec/Response.java b/src/main/java/com/retailsvc/http/spec/Response.java index b01a381..47da35a 100644 --- a/src/main/java/com/retailsvc/http/spec/Response.java +++ b/src/main/java/com/retailsvc/http/spec/Response.java @@ -2,4 +2,9 @@ import java.util.Map; +/** + * OpenAPI {@code response} object describing the payload an operation returns for a given status. + * + * @param content supported media types keyed by content-type string + */ public record Response(Map content) {} diff --git a/src/main/java/com/retailsvc/http/spec/Server.java b/src/main/java/com/retailsvc/http/spec/Server.java index e5de557..e8d0caa 100644 --- a/src/main/java/com/retailsvc/http/spec/Server.java +++ b/src/main/java/com/retailsvc/http/spec/Server.java @@ -2,7 +2,18 @@ import java.net.URI; +/** + * OpenAPI {@code server} entry. Only the {@code url} is retained; the first server's path becomes + * the HTTP context root. + * + * @param url full server URL as declared in the OpenAPI document + */ public record Server(String url) { + /** + * Returns the path component of {@link #url()}, used as the HTTP context root. + * + * @return the URL's path component + */ public String basePath() { return URI.create(url).getPath(); } diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index 4f76fc3..be64b6e 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -22,6 +22,22 @@ import java.util.Optional; import java.util.function.Function; +/** + * Parsed OpenAPI 3.1 specification used to drive request routing and validation. + * + * @param openapi OpenAPI document version (e.g. {@code "3.1.0"}) + * @param info top-level {@code info} object + * @param servers list of declared servers; the first one's path supplies {@link #basePath()} + * @param operations all operations across paths, in declaration order + * @param componentSchemas inline {@code components.schemas} map (raw name → schema) + * @param componentParameters inline {@code components.parameters} map (raw name → parameter) + * @param basePath URL path prefix derived from {@code servers[0].url} + * @param schemaRefIndex {@code $ref}-keyed view of {@code componentSchemas} for ref resolution + * @param parameterRefIndex {@code $ref}-keyed view of {@code componentParameters} + * @param extensions top-level {@code x-} extension keywords + * @param securitySchemes inline {@code components.securitySchemes} map + * @param security top-level security requirements applied to operations without their own + */ public record Spec( String openapi, Info info, @@ -66,6 +82,8 @@ static Map extractExtensions(Map raw) { * file's extension is not present, throws {@link IllegalStateException} — register your own * parser and call {@link #from(Map)} instead. * + * @param path filesystem path to the OpenAPI spec file + * @return the parsed specification * @throws UncheckedIOException if the file cannot be read * @throws IllegalStateException if the required parser is not on the classpath, or if the file * has an unrecognised extension @@ -109,6 +127,8 @@ public static Spec fromPath(Path path) { *

    To avoid the Gson dependency (e.g. when using Jackson), use {@link #fromJson(InputStream, * Function)} instead. * + * @param in stream containing the JSON OpenAPI spec + * @return the parsed specification * @throws NullPointerException if {@code in} is {@code null} * @throws UncheckedIOException if the stream cannot be read * @throws IllegalStateException if Gson is not on the classpath @@ -129,6 +149,9 @@ public static Spec fromJson(InputStream in) { * Spec spec = Spec.fromJson(in, bytes -> mapper.readValue(bytes, Map.class)); * } * + * @param in stream containing the JSON OpenAPI spec + * @param parser function that decodes the raw bytes into a map + * @return the parsed specification * @throws NullPointerException if {@code in} or {@code parser} is {@code null} * @throws UncheckedIOException if the stream cannot be read */ @@ -142,6 +165,8 @@ public static Spec fromJson(InputStream in, Function * classpath; otherwise throws {@link IllegalStateException}. The stream is fully consumed and * closed before this method returns. * + * @param in stream containing the YAML OpenAPI spec + * @return the parsed specification * @throws NullPointerException if {@code in} is {@code null} * @throws UncheckedIOException if the stream cannot be read * @throws IllegalStateException if SnakeYAML is not on the classpath @@ -205,6 +230,12 @@ private static Class loadOptional(String className, String format, String lib } } + /** + * Builds a {@code Spec} from a pre-parsed OpenAPI document. + * + * @param raw map produced by decoding the JSON or YAML spec + * @return the parsed specification + */ @SuppressWarnings("unchecked") public static Spec from(Map raw) { String openapi = (String) raw.get("openapi"); @@ -257,6 +288,13 @@ private static Map indexByRef(Map components, String p return Map.copyOf(out); } + /** + * Resolves a schema by its {@code $ref} string. + * + * @param ref full {@code $ref} value, e.g. {@code #/components/schemas/Pet} + * @return the referenced schema + * @throws IllegalArgumentException if no schema is registered under {@code ref} + */ public Schema resolveSchema(String ref) { Schema s = schemaRefIndex.get(ref); if (s == null) { @@ -265,6 +303,13 @@ public Schema resolveSchema(String ref) { return s; } + /** + * Resolves a parameter by its {@code $ref} string. + * + * @param ref full {@code $ref} value, e.g. {@code #/components/parameters/PetId} + * @return the referenced parameter + * @throws IllegalArgumentException if no parameter is registered under {@code ref} + */ public Parameter resolveParameter(String ref) { Parameter p = parameterRefIndex.get(ref); if (p == null) { diff --git a/src/main/java/com/retailsvc/http/spec/schema/AdditionalProperties.java b/src/main/java/com/retailsvc/http/spec/schema/AdditionalProperties.java index d2b8d81..cfced50 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AdditionalProperties.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AdditionalProperties.java @@ -1,9 +1,43 @@ package com.retailsvc.http.spec.schema; +/** + * Models the OpenAPI / JSON Schema {@code additionalProperties} keyword, which controls whether + * properties not listed in an object schema's {@code properties} map are permitted, and if so, + * whether they must conform to a constraint schema. + * + *

    This sealed interface has three variants that map directly to the three forms the keyword can + * take in a schema document: + * + *

      + *
    • {@link Allowed} — {@code additionalProperties: true} or omitted entirely. + *
    • {@link Forbidden} — {@code additionalProperties: false}. + *
    • {@link SchemaConstraint} — {@code additionalProperties: { ...schema... }}. + *
    + * + * @see JSON + * Schema 2020-12 — additionalProperties + */ public sealed interface AdditionalProperties { + /** + * Matches {@code additionalProperties: true} or the absence of the keyword (the JSON Schema + * default). Any extra property of any type is accepted during validation without further + * constraint. + */ record Allowed() implements AdditionalProperties {} + /** + * Matches {@code additionalProperties: false}. Any property present on an instance that is not + * declared in the schema's {@code properties} map causes validation to fail. + */ record Forbidden() implements AdditionalProperties {} + /** + * Matches {@code additionalProperties: {schema}}. Extra properties not declared in {@code + * properties} are permitted, but each such property's value must validate against the supplied + * constraint schema. + * + * @param schema the schema that every additional (undeclared) property value must satisfy + */ record SchemaConstraint(Schema schema) implements AdditionalProperties {} } diff --git a/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java index 761e75a..30daf5c 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java @@ -4,6 +4,20 @@ import java.util.Map; import java.util.Set; +/** + * Schema composition for the JSON Schema {@code allOf} keyword. + * + *

    A value is valid against this schema if and only if it validates successfully against every + * subschema in {@link #parts()}. The composition is conjunctive: each part contributes its own + * constraints, and all must hold simultaneously. + * + *

    {@link #types()} returns an empty set because {@code allOf} is itself type-agnostic — it does + * not declare a JSON type. The validator descends into each part and lets the parts assert any type + * constraints they carry. + * + * @param parts the subschemas that the value must all match + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node + */ public record AllOfSchema(List parts, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java index 1077b7d..e35fbe6 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java @@ -3,6 +3,20 @@ import java.util.Map; import java.util.Set; +/** + * The JSON Schema boolean schema {@code true} — accepts any value without constraint. + * + *

    In JSON Schema (and OpenAPI 3.1), a schema may be expressed as the literal boolean {@code + * true}, which is equivalent to an empty object schema {@code {}} and validates successfully + * against every instance. This record models that form. Its counterpart is {@link NeverSchema} (the + * boolean schema {@code false}), which rejects every instance. + * + *

    Because no type constraint applies to an "always" schema, {@link #types()} returns an empty + * set. + * + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node, keyed by + * extension name (including the {@code x-} prefix) with their raw parsed values. + */ public record AlwaysSchema(Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java index b2fe66a..0bb41a7 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java @@ -4,6 +4,24 @@ import java.util.Map; import java.util.Set; +/** + * Models the JSON Schema {@code anyOf} composition keyword: a value is valid when it validates + * against at least one of the listed option subschemas. + * + *

    Contrast with the sibling composition keywords: + * + *

      + *
    • {@link OneOfSchema} — the value must validate against exactly one option. + *
    • {@link AllOfSchema} — the value must validate against every option. + *
    + * + *

    {@link #types()} returns an empty set because an {@code anyOf} node does not itself constrain + * a JSON type; the validator descends into each option and lets the matching branch determine the + * effective type. + * + * @param options the candidate subschemas; at least one must match for the value to be valid + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node + */ public record AnyOfSchema(List options, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java b/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java index fcd1805..6bd519e 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java @@ -3,6 +3,21 @@ import java.util.Map; import java.util.Set; +/** + * Parsed JSON Schema node with {@code type: array}. + * + *

    Represents an array schema as defined by OpenAPI 3.1 / JSON Schema 2020-12. Instances are + * produced by the spec parser and consumed by the validator to enforce array-shaped payloads: + * element schema, cardinality bounds, and uniqueness. + * + * @param types the JSON Schema {@code type} set; typically the singleton {@code [ARRAY]}, or {@code + * [ARRAY, NULL]} when the schema is nullable + * @param items the schema applied to every element of the array + * @param minItems minimum number of elements; {@code null} means no lower bound + * @param maxItems maximum number of elements; {@code null} means no upper bound + * @param uniqueItems whether duplicate elements are rejected + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node + */ public record ArraySchema( Set types, Schema items, diff --git a/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java b/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java index 7a4e310..9e7aa23 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java @@ -3,5 +3,19 @@ import java.util.Map; import java.util.Set; +/** + * Parsed JSON Schema {@code type: boolean} node. + * + *

    A boolean schema accepts the JSON literals {@code true} or {@code false}. It carries no + * additional constraints beyond the type itself, so validation succeeds for any boolean value and + * fails for any other JSON kind. + * + * @param types the declared JSON Schema types for this node. Should be a singleton {@code + * [BOOLEAN]}, or include {@link TypeName#NULL} alongside {@code BOOLEAN} when the node is + * nullable (e.g. {@code type: [boolean, "null"]}). + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node, keyed by + * their full extension name (including the {@code x-} prefix). Never {@code null}; empty when + * no extensions are present. + */ public record BooleanSchema(Set types, Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java b/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java index 1076a0e..7867bf6 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java @@ -3,6 +3,15 @@ import java.util.Map; import java.util.Set; +/** + * Models the JSON Schema {@code const} keyword: an instance is valid if and only if its value is + * deeply equal to {@link #value()} (compared via {@link java.util.Objects#equals(Object, Object)}). + * + *

    {@link #types()} returns an empty set because the type is implied by the const value itself. + * + * @param value the required value; may be {@code null} to require a JSON {@code null} + * @param extensions OpenAPI {@code x-} extension keywords declared on this schema node + */ public record ConstSchema(Object value, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java b/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java index fe866af..31a10e8 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java @@ -4,6 +4,20 @@ import java.util.Map; import java.util.Set; +/** + * Models a JSON Schema {@code enum} constraint: an instance is valid only if it is deeply equal + * (via {@link java.util.Objects#equals(Object, Object)}) to one of the allowed {@link #values()}. + * + *

    {@link #types()} returns an empty set; the permitted type is implied by the enum values + * themselves rather than declared via a {@code type} keyword. + * + *

    Note: most string enums in OpenAPI are modelled as {@link StringSchema} with {@code + * enumValues()} populated. This {@code EnumSchema} covers the case where {@code enum} appears + * without an explicit {@code type} keyword. + * + * @param values the permitted values (any JSON-mappable Java type) + * @param extensions OpenAPI {@code x-} extension keywords on this schema node + */ public record EnumSchema(List values, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java b/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java index d088f4c..0f8d6ab 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java @@ -3,6 +3,18 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code type: integer}. + * + * @param types the declared types (may include {@link TypeName#NULL} for nullable) + * @param minimum inclusive lower bound, or {@code null} + * @param maximum inclusive upper bound, or {@code null} + * @param exclusiveMinimum exclusive lower bound, or {@code null} + * @param exclusiveMaximum exclusive upper bound, or {@code null} + * @param multipleOf required divisor, or {@code null} + * @param format optional format hint (e.g. {@code int32}, {@code int64}) + * @param extensions vendor extensions ({@code x-*} keys) + */ public record IntegerSchema( Set types, Long minimum, diff --git a/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java index 6600e24..66a1421 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java @@ -3,6 +3,12 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema boolean {@code false}: accepts nothing. Pairs with {@link AlwaysSchema} (boolean + * {@code true}). + * + * @param extensions vendor extensions ({@code x-*} keys) + */ public record NeverSchema(Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java index 8c99e8a..33de9a0 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java @@ -3,6 +3,12 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code not}: the value must NOT match the inner schema. + * + * @param schema the schema that the value must fail to validate against + * @param extensions vendor extensions ({@code x-*} keys) + */ public record NotSchema(Schema schema, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java index c2585ac..be1628f 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java @@ -3,6 +3,11 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code type: null}: only accepts the JSON {@code null} value. + * + * @param extensions vendor extensions ({@code x-*} keys) + */ public record NullSchema(Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java index af1008b..e1e20f8 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java @@ -3,6 +3,18 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code type: number}. + * + * @param types the declared types (may include {@link TypeName#NULL} for nullable) + * @param minimum inclusive lower bound, or {@code null} + * @param maximum inclusive upper bound, or {@code null} + * @param exclusiveMinimum exclusive lower bound, or {@code null} + * @param exclusiveMaximum exclusive upper bound, or {@code null} + * @param multipleOf required divisor, or {@code null} + * @param format optional format hint (e.g. {@code float}, {@code double}) + * @param extensions vendor extensions ({@code x-*} keys) + */ public record NumberSchema( Set types, Number minimum, diff --git a/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java b/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java index e2481e2..260ab44 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java @@ -4,6 +4,17 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code type: object}. + * + * @param types the declared types (may include {@link TypeName#NULL} for nullable) + * @param properties declared property name to schema mapping + * @param required names of required properties + * @param additionalProperties policy for properties not declared in {@code properties} + * @param minProperties minimum property count, or {@code null} + * @param maxProperties maximum property count, or {@code null} + * @param extensions vendor extensions ({@code x-*} keys) + */ public record ObjectSchema( Set types, Map properties, diff --git a/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java index 07a28ac..d8de4dd 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java @@ -4,6 +4,12 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code oneOf}: exactly one branch must match. + * + * @param options the candidate schemas + * @param extensions vendor extensions ({@code x-*} keys) + */ public record OneOfSchema(List options, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java b/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java index c2b8b56..52dcb6a 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java @@ -3,6 +3,12 @@ import java.util.Map; import java.util.Set; +/** + * A {@code $ref} to another schema. Resolved lazily by the validator. + * + * @param pointer the {@code $ref} pointer (e.g. {@code #/components/schemas/Foo}) + * @param extensions vendor extensions ({@code x-*} keys) + */ public record RefSchema(String pointer, Map extensions) implements Schema { @Override public Set types() { diff --git a/src/main/java/com/retailsvc/http/spec/schema/Schema.java b/src/main/java/com/retailsvc/http/spec/schema/Schema.java index 46f1c3d..693d183 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/Schema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/Schema.java @@ -3,6 +3,18 @@ import java.util.Map; import java.util.Set; +/** + * Root of the parsed OpenAPI / JSON Schema AST. + * + *

    This sealed interface has one variant per JSON Schema construct: {@link StringSchema}, {@link + * NumberSchema}, {@link IntegerSchema}, {@link BooleanSchema}, {@link ObjectSchema}, {@link + * ArraySchema}, {@link NullSchema}, {@link RefSchema}, {@link OneOfSchema}, {@link AnyOfSchema}, + * {@link AllOfSchema}, {@link NotSchema}, {@link ConstSchema}, {@link EnumSchema}, {@link + * AlwaysSchema}, and {@link NeverSchema}. + * + *

    Pattern-match against the variants in a {@code switch} to dispatch over the schema kind + * without resorting to {@code instanceof} chains. + */ public sealed interface Schema permits StringSchema, NumberSchema, @@ -20,7 +32,19 @@ public sealed interface Schema EnumSchema, AlwaysSchema, NeverSchema { + /** + * The JSON Schema {@code type} set for this node. Empty for combinator schemas ({@code allOf} / + * {@code anyOf} / {@code oneOf} / {@code not} / {@code const} / {@code enum} / {@code always} / + * {@code never}) where the type is derived from sub-schemas or is not applicable. + * + * @return the declared types + */ Set types(); + /** + * OpenAPI {@code x-}-prefixed extension keywords kept verbatim. + * + * @return immutable map of extension name to raw value + */ Map extensions(); } diff --git a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java index 3227a1f..bad4d88 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java +++ b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java @@ -7,6 +7,7 @@ import java.util.Map; import java.util.Set; +/** Parses raw OpenAPI / JSON Schema input ({@code Map} or {@code Boolean}) into {@link Schema}. */ public final class SchemaParser { private SchemaParser() {} @@ -22,6 +23,13 @@ static Map extractExtensions(Map raw) { return Map.copyOf(out); } + /** + * Parses a raw schema node into a {@link Schema}. + * + * @param raw a {@link Boolean} (JSON Schema shorthand) or a {@link Map} of schema keywords + * @return the parsed schema + * @throws IllegalArgumentException if {@code raw} is neither a boolean nor a map + */ public static Schema parse(Object raw) { if (raw instanceof Boolean b) { boolean allow = b; diff --git a/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java b/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java index e022b9b..bd05c41 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java @@ -4,6 +4,17 @@ import java.util.Map; import java.util.Set; +/** + * JSON Schema {@code type: string}. + * + * @param types the declared types (may include {@link TypeName#NULL} for nullable) + * @param pattern regex the value must match, or {@code null} + * @param minLength minimum length, or {@code null} + * @param maxLength maximum length, or {@code null} + * @param format optional format hint (e.g. {@code date-time}, {@code uuid}) + * @param enumValues allowed values, or {@code null} if unconstrained + * @param extensions vendor extensions ({@code x-*} keys) + */ public record StringSchema( Set types, String pattern, diff --git a/src/main/java/com/retailsvc/http/spec/schema/TypeName.java b/src/main/java/com/retailsvc/http/spec/schema/TypeName.java index ab20c98..7a12cdb 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/TypeName.java +++ b/src/main/java/com/retailsvc/http/spec/schema/TypeName.java @@ -1,14 +1,29 @@ package com.retailsvc.http.spec.schema; +/** The seven standard JSON Schema primitive types. */ public enum TypeName { + /** JSON string. */ STRING, + /** JSON number (any numeric value). */ NUMBER, + /** JSON integer (a number with no fractional part). */ INTEGER, + /** JSON boolean ({@code true} or {@code false}). */ BOOLEAN, + /** JSON object. */ OBJECT, + /** JSON array. */ ARRAY, + /** JSON null. */ NULL; + /** + * Maps a JSON Schema {@code type} string to the matching {@link TypeName}. + * + * @param name lowercase JSON Schema type name (e.g. {@code "string"}) + * @return the corresponding constant + * @throws IllegalArgumentException if {@code name} is not a known JSON Schema type + */ public static TypeName fromJsonSchema(String name) { return switch (name) { case "string" -> STRING; diff --git a/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java b/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java index c7dd140..7459312 100644 --- a/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java +++ b/src/main/java/com/retailsvc/http/spec/security/SecurityRequirement.java @@ -6,8 +6,14 @@ /** * One OR-branch in a {@code security} list. Each entry in {@link #schemes} is AND-ed: every scheme * name must be satisfied for the requirement to hold. Scopes are preserved but unused in v1. + * + * @param schemes map from security-scheme name (as declared in {@code components.securitySchemes}) + * to the list of OAuth2 / OpenID Connect scopes required for that scheme. An empty list means + * "any scope" / "no scopes required" (also used for non-OAuth schemes such as API key or HTTP + * auth where scopes do not apply). */ public record SecurityRequirement(Map> schemes) { + /** Canonical constructor that defensively copies {@code schemes} into an unmodifiable map. */ public SecurityRequirement { schemes = Map.copyOf(schemes); } diff --git a/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java b/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java index facf62d..5af0a9b 100644 --- a/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java +++ b/src/main/java/com/retailsvc/http/spec/security/SecurityScheme.java @@ -2,24 +2,78 @@ import java.util.Optional; +/** + * Models an OpenAPI 3.1 {@code securitySchemes} entry. + * + *

    This sealed interface enumerates the security scheme variants the library understands natively + * ({@link ApiKey}, {@link HttpBearer}, {@link HttpBasic}) and provides an {@link Unsupported} + * fallback so that specs declaring scheme types not yet implemented in v1 (such as {@code oauth2}, + * {@code openIdConnect} or {@code mutualTLS}) can still be parsed without failing the whole spec + * load. + * + *

    Each permitted variant is a {@code record}, enabling exhaustive pattern matching at the call + * site. + */ public sealed interface SecurityScheme permits SecurityScheme.ApiKey, SecurityScheme.HttpBearer, SecurityScheme.HttpBasic, SecurityScheme.Unsupported { + /** + * OpenAPI {@code type: apiKey} security scheme. + * + *

    Represents an API key transported either as a header, query parameter or cookie. The {@code + * name} corresponds to the OpenAPI {@code name} property and identifies the header / query / + * cookie key whose value carries the credential. + * + * @param name the name of the header, query parameter or cookie that carries the API key + * @param location where the API key is transported on the request + */ record ApiKey(String name, Location location) implements SecurityScheme { + + /** + * Transport location for an {@link ApiKey} credential, mirroring the OpenAPI {@code in} + * property of an {@code apiKey} security scheme. + */ public enum Location { + /** Credential is sent as an HTTP request header. */ HEADER, + /** Credential is sent as a URL query parameter. */ QUERY, + /** Credential is sent as an HTTP cookie. */ COOKIE } } + /** + * OpenAPI {@code type: http} security scheme with {@code scheme: bearer}. + * + *

    Represents bearer-token authentication (typically {@code Authorization: Bearer + * <token>}). The optional {@code bearerFormat} is a free-form hint from the spec (e.g. + * {@code JWT}) and is informational only. + * + * @param bearerFormat optional OpenAPI {@code bearerFormat} hint describing the token format + */ record HttpBearer(Optional bearerFormat) implements SecurityScheme {} + /** + * OpenAPI {@code type: http} security scheme with {@code scheme: basic}. + * + *

    Represents HTTP Basic authentication as defined by RFC 7617. Carries no configuration beyond + * its type. + */ record HttpBasic() implements SecurityScheme {} - /** Parsed but unsupported in v1 (oauth2, openIdConnect, mutualTLS). */ + /** + * Fallback for security scheme types parsed from the spec but not yet supported in v1 (notably + * {@code oauth2}, {@code openIdConnect} and {@code mutualTLS}). + * + *

    Keeping the original {@code type} string allows callers to surface a meaningful diagnostic + * (or to layer in custom handling) without rejecting the whole OpenAPI document at parse time. + * + * @param type the raw OpenAPI {@code type} string as it appeared in the spec (e.g. {@code + * oauth2}, {@code openIdConnect}, {@code mutualTLS}) + */ record Unsupported(String type) implements SecurityScheme {} } diff --git a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java index 3fa3273..bc6e252 100644 --- a/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java +++ b/src/main/java/com/retailsvc/http/spec/security/SecuritySchemeParser.java @@ -12,9 +12,20 @@ import java.util.Map; import java.util.Optional; +/** + * Parses raw {@code securitySchemes} and {@code security} fragments from an OpenAPI document into + * typed {@link SecurityScheme} and {@link SecurityRequirement} instances. + */ public final class SecuritySchemeParser { private SecuritySchemeParser() {} + /** + * Parses a single {@code securitySchemes} entry into a {@link SecurityScheme}. + * + * @param raw the raw map from {@code components.securitySchemes.} + * @return the typed security scheme, or {@link SecurityScheme.Unsupported} for unknown types + * @throws IllegalArgumentException if required fields are missing + */ public static SecurityScheme parse(Map raw) { String type = (String) raw.get("type"); if (type == null) { @@ -36,6 +47,13 @@ private static SecurityScheme parseApiKey(Map raw) { return new ApiKey(name, Location.valueOf(in.toUpperCase(Locale.ROOT))); } + /** + * Parses an OpenAPI {@code security} list (operation- or root-level) into typed requirements. + * + * @param raw the raw list of requirement objects, may be {@code null} or empty + * @return an immutable list of {@link SecurityRequirement}s; empty if {@code raw} is null/empty + * @throws IllegalArgumentException if any entry is not a JSON object + */ public static List parseRequirements(List raw) { if (raw == null || raw.isEmpty()) { return List.of(); diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index fb66f20..6ed9c2b 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -44,6 +44,42 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +/** + * Library-provided implementation of {@link Validator}. + * + *

    Validates a parsed JSON value (a {@link java.util.Map}, {@link java.util.List}, {@link + * String}, {@link Number}, {@link Boolean}, or {@code null}) against a {@link Schema} AST using + * {@code switch} pattern-match dispatch — there are no {@code instanceof} chains. Dispatch is + * delegated to the sealed {@link Schema} hierarchy so each schema kind is handled by a dedicated + * arm. + * + *

    On the first validation failure, this class throws a {@link ValidationException} carrying a + * {@link ValidationError} that records the JSON Pointer path to the offending node, the failing + * JSON Schema keyword (for example {@code "type"}, {@code "required"}, {@code "format"}), and the + * rejected value. The server's exception filter renders that error as an {@code RFC 7807} {@code + * 400 application/problem+json} response. + * + *

    Supported {@code string} formats: + * + *

      + *
    • {@code uuid} + *
    • {@code date} + *
    • {@code date-time} + *
    • {@code email} + *
    • {@code uri} + *
    • {@code uri-reference} + *
    • {@code hostname} + *
    • {@code ipv4} + *
    • {@code ipv6} + *
    • {@code regex} + *
    • {@code byte} (base64-encoded) + *
    • {@code binary} + *
    • {@code password} + *
    + * + *

    Supported {@code integer} formats: {@code int32}, {@code int64}. Supported {@code number} + * formats: {@code float}, {@code double}. + */ public final class DefaultValidator implements Validator { private static final String FORMAT_KEYWORD = "format"; @@ -103,6 +139,19 @@ private record NumberFormatCheck(DoublePredicate isValid, String message) {} private final Function refResolver; private final ConcurrentMap compiledPatterns = new ConcurrentHashMap<>(); + /** + * Creates a validator that resolves {@code $ref} references through the supplied function. + * + *

    The {@code refResolver} is consulted lazily during each call to {@link #validate}: whenever + * a {@link com.retailsvc.http.spec.schema.RefSchema} is encountered, its URI is passed to the + * resolver and validation continues against the returned target {@link Schema}. Because + * resolution happens per-validate (not eagerly at construction), the spec's ref map can be passed + * by reference and the validator will pick up its current contents on every request. + * + * @param refResolver function that maps a {@code $ref} URI string to the {@link Schema} it points + * to; must not be {@code null} and must return a non-{@code null} schema for every ref + * reachable from the validated documents + */ public DefaultValidator(Function refResolver) { this.refResolver = refResolver; } diff --git a/src/main/java/com/retailsvc/http/validate/ValidationError.java b/src/main/java/com/retailsvc/http/validate/ValidationError.java index 3cd3423..e3f1ca4 100644 --- a/src/main/java/com/retailsvc/http/validate/ValidationError.java +++ b/src/main/java/com/retailsvc/http/validate/ValidationError.java @@ -1,4 +1,12 @@ package com.retailsvc.http.validate; +/** + * A single validation failure produced while checking a request against an OpenAPI schema. + * + * @param pointer JSON Pointer to the offending location in the request payload + * @param keyword the JSON Schema keyword that failed (e.g. {@code "required"}, {@code "type"}) + * @param message human-readable description of the failure + * @param rejectedValue the value that failed validation, or {@code null} if not applicable + */ public record ValidationError( String pointer, String keyword, String message, Object rejectedValue) {} diff --git a/src/main/java/com/retailsvc/http/validate/Validator.java b/src/main/java/com/retailsvc/http/validate/Validator.java index 872632d..64f4e54 100644 --- a/src/main/java/com/retailsvc/http/validate/Validator.java +++ b/src/main/java/com/retailsvc/http/validate/Validator.java @@ -1,8 +1,25 @@ package com.retailsvc.http.validate; +import com.retailsvc.http.ValidationException; import com.retailsvc.http.spec.schema.Schema; +/** + * Validator contract: validates a value against a {@link Schema}, throwing {@link + * ValidationException} on the first failure. + * + *

    {@link DefaultValidator} is the library-provided implementation. + */ public interface Validator { - /** Throws ValidationException on first failure. */ + /** + * Validates {@code value} against {@code schema}, throwing {@link ValidationException} on the + * first failure. + * + * @param value the value to validate (may be {@code null} — a {@code null} is accepted only when + * the schema permits the {@code null} type) + * @param schema the schema to validate against + * @param pointer JSON Pointer prefix used to qualify the path of any failure (use empty string + * for root) + * @throws ValidationException on the first failure + */ void validate(Object value, Schema schema, String pointer); }