From 5ba93fb83f179504d71170b00fbe1d998c3152bf Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 16:48:15 +0200 Subject: [PATCH 01/14] docs: Design for extra (non-OpenAPI) handlers + builder --- .../specs/2026-05-12-extra-handlers-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-extra-handlers-design.md diff --git a/docs/superpowers/specs/2026-05-12-extra-handlers-design.md b/docs/superpowers/specs/2026-05-12-extra-handlers-design.md new file mode 100644 index 0000000..9729508 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-extra-handlers-design.md @@ -0,0 +1,130 @@ +# Extra (non-OpenAPI) handlers + builder + +**Date:** 2026-05-12 +**Status:** Design — ready for implementation plan + +## Problem + +`OpenApiServer` mounts handlers only by OpenAPI `operationId`. Everything outside the spec falls through to a catch-all `/` 404. Consumers need a way to expose operational endpoints that are not part of the API contract — for example `/alive` for liveness probes, or a spec-accessor route that serves the OpenAPI document itself at a stable URL. + +These endpoints must bypass OpenAPI parameter / body validation entirely — they have no `operationId` and no schema. + +## Goals + +1. Allow callers to register extra `HttpHandler` instances at arbitrary URL paths, outside the OpenAPI spec. +2. Provide a small set of built-in helpers in `Handlers` for the most common cases (liveness, classpath-resource serving). +3. Replace the constructor sprawl on `OpenApiServer` with a builder, since this change adds a fifth parameter and more are likely in future waves. +4. Keep the existing public constructors for source/binary back-compat. + +## Non-goals + +- Routing by HTTP method on extra paths beyond what the helpers do internally (callers can compose their own `HttpHandler` if they need richer dispatch). +- Hot-mounting / hot-unmounting handlers after `build()` — registration is build-time only. +- Built-in readiness probes or metrics endpoints — out of scope; callers can supply their own `HttpHandler`. + +## Design + +### Public API — builder + +```java +OpenApiServer server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(operationHandlers) + .exceptionHandler(exceptionHandler) // optional, defaults to Handlers.defaultExceptionHandler() + .port(8080) // optional, default 8080 + .addHandler("/alive", Handlers.aliveHandler()) + .addHandler("/schemas/v1/openapi.yaml", + Handlers.specHandler("/schemas/v1/openapi.yaml")) + .build(); // throws IOException, starts the server +``` + +Rules: + +- `OpenApiServer.builder()` returns a fresh `OpenApiServer.Builder`. +- `spec`, `jsonMapper`, `handlers` are required. `build()` throws `NullPointerException` if any is missing — matches current constructor behavior. +- `exceptionHandler` is optional. If null/unset, defaults to `Handlers.defaultExceptionHandler()` (current behavior). +- `port` defaults to `8080` (current behavior). +- `addHandler(String path, HttpHandler handler)` adds one entry. `path` is the URL path; `handler` is the user's `HttpHandler`. Both non-null. +- Calling `addHandler` twice with the same `path` → `IllegalStateException` from the second `addHandler` call (fail fast, not deferred to `build()`). +- An extra path equal to `spec.basePath()` is detected at `build()` time and rejected with `IllegalStateException` before `HttpServer.createContext` is called, with a clear message naming both the extra path and the OpenAPI base path. +- Existing two `OpenApiServer` constructors stay as thin delegators that call `builder()...build()`, for back-compat. + +### Wiring inside `OpenApiServer` + +For each `addHandler(path, handler)` entry, after the OpenAPI context is created and before the catch-all `/` 404 is registered: + +```java +HttpContext extraCtx = httpServer.createContext(path); +extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); +extraCtx.setHandler(handler); +``` + +Order of context creation inside `OpenApiServer`: + +1. OpenAPI context at `spec.basePath()` (full validation pipeline). +2. Each `addHandler` path (extras), each with `ExceptionFilter` only. +3. Catch-all `/` → `Handlers.notFoundHandler()`. + +`HttpServer` resolves contexts by longest-prefix match, so creation order does not affect correctness — but two contexts at the same path is undefined behavior. Duplicate extras are caught by `addHandler` itself (see API rules above). An extra path equal to `spec.basePath()` is caught at the start of `build()` and rejected with `IllegalStateException` before any `HttpServer.createContext` call. + +Extra handlers do **not** receive `RequestPreparationFilter` (no body read, no validation, no `operationId` resolution) and are not dispatched through `DispatchHandler`. They are mounted directly. `ExceptionFilter` wraps them so any uncaught exception flows through the user-supplied `ExceptionHandler`, giving operational endpoints the same RFC-7807 error envelope as API routes. + +### Built-in helpers in `Handlers` + +```java +/** 204 No Content on GET/HEAD; 405 with Allow: GET, HEAD on other methods. */ +public static HttpHandler aliveHandler(); + +/** + * Serves a classpath resource. Content-Type is inferred from the file extension: + * .json → application/json + * .yaml | .yml → application/yaml + * .txt → text/plain; charset=utf-8 + * anything else → application/octet-stream + * + * The resource is loaded eagerly when this method is called and cached in memory. + * If the resource cannot be found on the classpath, this method throws + * IllegalArgumentException — so misconfiguration fails at server build, not at + * first request. + * + * Responds 200 on GET/HEAD; 405 with Allow: GET, HEAD on other methods. + * + * @param classpathResource absolute classpath path, e.g. "/schemas/v1/openapi.yaml" + */ +public static HttpHandler specHandler(String classpathResource); +``` + +Notes: + +- `aliveHandler` sends `sendResponseHeaders(204, -1)` (no body). +- `specHandler` reads bytes via `Handlers.class.getResourceAsStream(classpathResource)`. Null → `IllegalArgumentException("classpath resource not found: " + classpathResource)`. Bytes are held in the closure for the handler's lifetime. +- Content-Length is set to the cached byte count; HEAD requests get headers only with the same Content-Length. +- No caching headers (no `ETag`, no `Cache-Control`). Callers who need them wrap their own handler. + +### Testing + +Unit tests (additions to `HandlersTest`): + +- `aliveHandler` returns 204 with no body on GET. +- `aliveHandler` returns 204 with no body on HEAD. +- `aliveHandler` returns 405 with `Allow: GET, HEAD` on POST, PUT, DELETE. +- `specHandler` returns the resource bytes verbatim with inferred content type for `.json`, `.yaml`, `.yml`, `.txt`, and an unknown extension. +- `specHandler` throws `IllegalArgumentException` at construction when the classpath resource is missing. +- `specHandler` returns 405 with `Allow: GET, HEAD` on non-GET/HEAD methods. + +Integration tests (additions, in a new `OpenApiServerBuilderIT` or extending `OpenApiServerIT`): + +- Minimal builder smoke test: only required fields → server starts, OpenAPI route + at least one extra reachable. +- Extra handler bypasses validation: `addHandler("/alive", Handlers.aliveHandler())` is reachable and returns 204 even though `/alive` is not in the OpenAPI spec. +- Extra handler exception is delivered to `ExceptionHandler`: register a handler that throws `RuntimeException`, assert the configured `ExceptionHandler` writes the RFC-7807 envelope. +- Duplicate `addHandler` path → `IllegalStateException` thrown from the second `addHandler` call. +- Extra path equal to `spec.basePath()` → `IllegalStateException` from `build()` with a message naming both paths. +- Existing `OpenApiServer` constructors still work (back-compat smoke test). + +## Out of scope + +- HTTP method-aware routing for extras beyond what the helpers implement. +- Readiness probes, metrics, or any other built-in operational endpoint past `aliveHandler` and `specHandler`. +- Per-handler filter customization (e.g. attaching custom filters to one extra and not another). +- Dynamic registration after `build()`. From f1e79376771b29aeb185aceda378483f3088571a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 16:49:16 +0200 Subject: [PATCH 02/14] docs: Note README updates in extra-handlers design --- .../superpowers/specs/2026-05-12-extra-handlers-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-05-12-extra-handlers-design.md b/docs/superpowers/specs/2026-05-12-extra-handlers-design.md index 9729508..d023125 100644 --- a/docs/superpowers/specs/2026-05-12-extra-handlers-design.md +++ b/docs/superpowers/specs/2026-05-12-extra-handlers-design.md @@ -122,6 +122,14 @@ Integration tests (additions, in a new `OpenApiServerBuilderIT` or extending `Op - Extra path equal to `spec.basePath()` → `IllegalStateException` from `build()` with a message naming both paths. - Existing `OpenApiServer` constructors still work (back-compat smoke test). +### Documentation + +Update `README.md`: + +- Replace the existing constructor example (around line 86) with the new builder form. +- Add a short subsection showing `addHandler` with `Handlers.aliveHandler()` and `Handlers.specHandler(...)`, and noting that extra handlers bypass OpenAPI validation but still flow through the configured `ExceptionHandler`. +- Mention that the original constructors remain for back-compat. + ## Out of scope - HTTP method-aware routing for extras beyond what the helpers implement. From 2e8052286ec81292e7c6a063f459ecc9735a2853 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 16:52:34 +0200 Subject: [PATCH 03/14] feat: Add ClasspathResourceHandler for static resource serving --- .../plans/2026-05-12-extra-handlers.md | 860 ++++++++++++++++++ .../internal/ClasspathResourceHandler.java | 58 ++ .../http/internal/MethodLimitedHandler.java | 35 + 3 files changed, 953 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-extra-handlers.md create mode 100644 src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java create mode 100644 src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java diff --git a/docs/superpowers/plans/2026-05-12-extra-handlers.md b/docs/superpowers/plans/2026-05-12-extra-handlers.md new file mode 100644 index 0000000..9e16582 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-extra-handlers.md @@ -0,0 +1,860 @@ +# Extra (non-OpenAPI) Handlers + Builder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow callers to register `HttpHandler` instances at arbitrary URL paths outside the OpenAPI spec, via a new `OpenApiServer.Builder`. Ship two built-in helpers (`Handlers.aliveHandler`, `Handlers.specHandler`). + +**Architecture:** Add a nested `Builder` to `OpenApiServer` that collects required fields and a `LinkedHashMap` of "extras". `build()` instantiates the server via a new package-private constructor that mounts each extra as its own `HttpContext` wrapped in `ExceptionFilter` only — no validation, no dispatch. Existing public constructors stay as thin delegators for back-compat. Two helpers in `Handlers`: `aliveHandler` (204 No Content on GET/HEAD, 405 with `Allow: GET, HEAD` otherwise) and `specHandler(String classpathResource)` (eager-load bytes, content-type by extension). + +**Tech Stack:** Java 25, `com.sun.net.httpserver`, JUnit 5, AssertJ, java.net.http HttpClient. Build: Maven. + +**Spec:** `docs/superpowers/specs/2026-05-12-extra-handlers-design.md` + +--- + +## File Structure + +**Create:** +- `src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java` — package-private `HttpHandler` backing `Handlers.specHandler`, holds cached bytes + content type. +- `src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java` — package-private `HttpHandler` wrapper that allows only GET/HEAD and returns 405 with `Allow: GET, HEAD` otherwise. Shared by `aliveHandler` and `specHandler`. +- `src/test/java/com/retailsvc/http/HandlersTest.java` — unit tests for `aliveHandler` and `specHandler`. +- `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java` — unit tests for the builder validation rules. +- `src/test/java/com/retailsvc/http/ExtraHandlersIT.java` — integration tests for extras mounted on a running server. + +**Modify:** +- `src/main/java/com/retailsvc/http/OpenApiServer.java` — add nested `Builder`, add package-private constructor accepting extras, delegate existing public constructors to the builder. +- `src/main/java/com/retailsvc/http/Handlers.java` — add `aliveHandler()` and `specHandler(String)` public statics. +- `README.md` — replace constructor example with builder example, add subsection on extras. + +--- + +## Task 1: `MethodLimitedHandler` (shared GET/HEAD-only wrapper) + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java` +- Test: `src/test/java/com/retailsvc/http/HandlersTest.java` (we exercise this indirectly via `aliveHandler`/`specHandler`, but cover its behavior here through the public helpers in later tasks) + +- [ ] **Step 1: Create the wrapper class** + +```java +package com.retailsvc.http.internal; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; + +/** + * Wraps a delegate handler so it answers only GET and HEAD. Other methods produce 405 with + * {@code Allow: GET, HEAD}. + */ +public final class MethodLimitedHandler implements HttpHandler { + + private static final String ALLOW = "GET, HEAD"; + + private final HttpHandler delegate; + + public MethodLimitedHandler(HttpHandler delegate) { + this.delegate = delegate; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + if ("GET".equals(method) || "HEAD".equals(method)) { + delegate.handle(exchange); + return; + } + try (exchange) { + exchange.getResponseHeaders().add("Allow", ALLOW); + exchange.sendResponseHeaders(HTTP_BAD_METHOD, -1); + } + } +} +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q compile` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java +git commit -m "feat: add MethodLimitedHandler wrapper for GET/HEAD-only routes" +``` + +--- + +## Task 2: `ClasspathResourceHandler` (bytes cached, content-type inferred) + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java` + +- [ ] **Step 1: Create the handler** + +```java +package com.retailsvc.http.internal; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +/** + * Serves bytes loaded eagerly from a classpath resource. Content-Type is inferred from the file + * extension. Throws {@link IllegalArgumentException} if the resource is missing. + */ +public final class ClasspathResourceHandler implements HttpHandler { + + private final byte[] bytes; + private final String contentType; + + public ClasspathResourceHandler(String classpathResource) { + try (InputStream in = ClasspathResourceHandler.class.getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalArgumentException("classpath resource not found: " + classpathResource); + } + this.bytes = in.readAllBytes(); + } catch (IOException io) { + throw new IllegalArgumentException("failed reading classpath resource: " + classpathResource, io); + } + this.contentType = contentTypeFor(classpathResource); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", contentType); + if ("HEAD".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().add("Content-Length", String.valueOf(bytes.length)); + exchange.sendResponseHeaders(200, -1); + return; + } + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + } + } + + private static String contentTypeFor(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".txt")) { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; + } +} +``` + +- [ ] **Step 2: Compile** + +Run: `mvn -q compile` +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java +git commit -m "feat: add ClasspathResourceHandler for static resource serving" +``` + +--- + +## Task 3: `Handlers.aliveHandler()` and `Handlers.specHandler(...)` — TDD + +**Files:** +- Create: `src/test/java/com/retailsvc/http/HandlersTest.java` +- Modify: `src/main/java/com/retailsvc/http/Handlers.java` + +- [ ] **Step 1: Write failing tests for `aliveHandler`** + +Create `src/test/java/com/retailsvc/http/HandlersTest.java`: + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.List; +import com.sun.net.httpserver.Headers; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HandlersTest { + + @Test + void aliveHandlerReturns204OnGet() throws IOException { + HttpExchange ex = newExchange("GET"); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(204, -1); + } + + @Test + void aliveHandlerReturns204OnHead() throws IOException { + HttpExchange ex = newExchange("HEAD"); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(204, -1); + } + + @Test + void aliveHandlerReturns405OnPost() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(405, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } + + private static HttpExchange newExchange(String method) { + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getResponseHeaders()).thenReturn(new Headers()); + return ex; + } +} +``` + +- [ ] **Step 2: Run tests, expect failure (method not defined)** + +Run: `mvn -q test -Dtest=HandlersTest` +Expected: COMPILE FAILURE: `cannot find symbol: method aliveHandler()` + +- [ ] **Step 3: Add `aliveHandler` to `Handlers`** + +In `src/main/java/com/retailsvc/http/Handlers.java`, add the import and method: + +```java +import com.retailsvc.http.internal.MethodLimitedHandler; +``` + +Append before the closing brace of the class: + +```java + /** Returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} otherwise. */ + public static HttpHandler aliveHandler() { + return new MethodLimitedHandler( + exchange -> { + try (exchange) { + exchange.sendResponseHeaders(204, -1); + } + }); + } +``` + +- [ ] **Step 4: Run tests, expect pass** + +Run: `mvn -q test -Dtest=HandlersTest` +Expected: 3 tests pass. + +- [ ] **Step 5: Write failing tests for `specHandler`** + +Append to `HandlersTest`: + +```java + @Test + void specHandlerServesYamlWithInferredContentType() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.specHandler("/openapi.yaml").handle(ex); + + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/yaml"); + verify(ex).sendResponseHeaders(org.mockito.ArgumentMatchers.eq(200), org.mockito.ArgumentMatchers.longThat(n -> n > 0)); + assertThat(body.toByteArray()).isNotEmpty(); + } + + @Test + void specHandlerInfersJsonContentType() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + + Handlers.specHandler("/openapi.json").handle(ex); + + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/json"); + } + + @Test + void specHandlerThrowsAtConstructionForMissingResource() { + assertThatThrownBy(() -> Handlers.specHandler("/does-not-exist.yaml")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("/does-not-exist.yaml"); + } + + @Test + void specHandlerReturns405OnPost() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + + Handlers.specHandler("/openapi.yaml").handle(ex); + + verify(ex).sendResponseHeaders(405, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } +``` + +- [ ] **Step 6: Run tests, expect compile failure** + +Run: `mvn -q test -Dtest=HandlersTest` +Expected: COMPILE FAILURE: `cannot find symbol: method specHandler(String)` + +- [ ] **Step 7: Add `specHandler` to `Handlers`** + +Add import: + +```java +import com.retailsvc.http.internal.ClasspathResourceHandler; +``` + +Append before the closing brace of the class: + +```java + /** + * Serves a classpath resource. Content-Type is inferred from the file extension. The resource is + * loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}. + * + * @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml} + */ + public static HttpHandler specHandler(String classpathResource) { + return new MethodLimitedHandler(new ClasspathResourceHandler(classpathResource)); + } +``` + +- [ ] **Step 8: Run tests, expect pass** + +Run: `mvn -q test -Dtest=HandlersTest` +Expected: 7 tests pass (3 alive + 4 spec). + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Handlers.java src/test/java/com/retailsvc/http/HandlersTest.java +git commit -m "feat: add Handlers.aliveHandler and Handlers.specHandler" +``` + +--- + +## Task 4: `OpenApiServer.Builder` — validation rules (unit tests) + +**Files:** +- Create: `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java` +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +- [ ] **Step 1: Write failing builder unit tests** + +Create `src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java`: + +```java +package com.retailsvc.http; + +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import static java.util.Collections.emptyMap; + +import com.retailsvc.http.spec.Spec; +import com.sun.net.httpserver.HttpHandler; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class OpenApiServerBuilderTest { + + private final Spec spec = testSpec(); + private final JsonMapper jsonMapper = body -> new java.util.HashMap(); + + @Test + void buildsWithRequiredFieldsOnly() { + assertDoesNotThrow( + () -> { + try (var _ = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .build()) { + // close on exit + } + }); + } + + @Test + void rejectsDuplicateExtraPathOnSecondAddHandler() { + OpenApiServer.Builder b = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .addHandler("/alive", Handlers.aliveHandler()); + + assertThatThrownBy(() -> b.addHandler("/alive", Handlers.aliveHandler())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("/alive"); + } + + @Test + void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { + // testSpec() uses "/api" as the basePath (from servers[0].url = http://localhost:8080/api). + assertThatThrownBy( + () -> + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .addHandler("/api", Handlers.aliveHandler()) + .port(0) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("/api"); + } + + @Test + void rejectsNullSpec() { + assertThatThrownBy( + () -> + OpenApiServer.builder() + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Spec"); + } + + private static Spec testSpec() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "Test API", "version", "1.0"), + "servers", List.of(Map.of("url", "http://localhost:8080/api")), + "paths", emptyMap()); + return Spec.from(raw); + } +} +``` + +- [ ] **Step 2: Run tests, expect compile failure** + +Run: `mvn -q test -Dtest=OpenApiServerBuilderTest` +Expected: COMPILE FAILURE: `cannot find symbol: method builder()`. + +- [ ] **Step 3: Add Builder to `OpenApiServer`** + +In `src/main/java/com/retailsvc/http/OpenApiServer.java`, add to the imports: + +```java +import java.util.LinkedHashMap; +``` + +Inside the `OpenApiServer` class (e.g. just above the closing brace), add: + +```java + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Spec spec; + private JsonMapper jsonMapper; + private Map handlers; + private ExceptionHandler exceptionHandler; + private int port = DEFAULT_PORT; + private final LinkedHashMap extras = new LinkedHashMap<>(); + + private Builder() {} + + public Builder spec(Spec spec) { + this.spec = spec; + return this; + } + + public Builder jsonMapper(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + return this; + } + + public Builder handlers(Map handlers) { + this.handlers = handlers; + return this; + } + + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder addHandler(String path, HttpHandler handler) { + requireNonNull(path, "path must not be null"); + requireNonNull(handler, "handler must not be null"); + if (extras.containsKey(path)) { + throw new IllegalStateException("duplicate extra handler path: " + path); + } + extras.put(path, handler); + return this; + } + + public OpenApiServer build() throws IOException { + requireNonNull(spec, "Spec must not be null"); + requireNonNull(jsonMapper, "JsonMapper must not be null"); + requireNonNull(handlers, "handlers must not be null"); + String basePath = Optional.ofNullable(spec.basePath()).orElse("/"); + for (String extraPath : extras.keySet()) { + if (extraPath.equals(basePath)) { + throw new IllegalStateException( + "extra handler path " + extraPath + " collides with OpenAPI base path " + basePath); + } + } + return new OpenApiServer(spec, jsonMapper, handlers, exceptionHandler, port, extras); + } + } +``` + +- [ ] **Step 4: Add the package-private constructor with extras** + +Inside `OpenApiServer`, add a new constructor accepting the extras map. Refactor the existing public constructor to delegate, and add filter wiring for extras: + +```java + OpenApiServer( + Spec spec, + JsonMapper jsonMapper, + Map handlers, + ExceptionHandler exceptionHandler, + int port, + Map extras) + throws IOException { + + requireNonNull(spec, "Spec must not be null"); + requireNonNull(jsonMapper, "JsonMapper must not be null"); + requireNonNull(handlers, "handlers must not be null"); + if (exceptionHandler == null) { + LOG.warn("No ExceptionHandler set, using default"); + exceptionHandler = Handlers.defaultExceptionHandler(); + } + + long t0 = System.currentTimeMillis(); + Router router = new Router(spec.operations()); + DefaultValidator validator = new DefaultValidator(spec::resolveSchema); + + this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory())); + + HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); + ctx.getFilters().add(new ExceptionFilter(exceptionHandler)); + ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, jsonMapper)); + ctx.setHandler(new DispatchHandler(handlers)); + + for (Map.Entry e : extras.entrySet()) { + HttpContext extraCtx = httpServer.createContext(e.getKey()); + extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); + extraCtx.setHandler(e.getValue()); + } + + httpServer.createContext("/", Handlers.notFoundHandler()); + httpServer.start(); + + LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0); + } +``` + +Update the two existing public constructors to delegate: + +```java + public OpenApiServer( + Spec spec, + JsonMapper jsonMapper, + Map handlers, + ExceptionHandler exceptionHandler) + throws IOException { + this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of()); + } + + public OpenApiServer( + Spec spec, + JsonMapper jsonMapper, + Map handlers, + ExceptionHandler exceptionHandler, + int port) + throws IOException { + this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of()); + } +``` + +- [ ] **Step 5: Run builder unit tests** + +Run: `mvn -q test -Dtest=OpenApiServerBuilderTest` +Expected: 4 tests pass. + +- [ ] **Step 6: Run full unit test suite (back-compat check)** + +Run: `mvn -q test` +Expected: All existing tests still pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +git commit -m "feat: add OpenApiServer.Builder with extra-handler support" +``` + +--- + +## Task 5: Integration test — extras mounted on a running server + +**Files:** +- Create: `src/test/java/com/retailsvc/http/ExtraHandlersIT.java` + +- [ ] **Step 1: Write failing IT** + +```java +package com.retailsvc.http; + +import static com.retailsvc.http.Handlers.defaultExceptionHandler; +import static java.net.http.HttpRequest.BodyPublishers.noBody; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExtraHandlersIT extends ServerBaseTest { + + @Test + void aliveExtraReturns204AndBypassesValidation() throws Exception { + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/alive", Handlers.aliveHandler()) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/alive")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.body()).isEmpty(); + } + } + + @Test + void specHandlerServesClasspathResource() throws Exception { + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/openapi.yaml", Handlers.specHandler("/openapi.yaml")) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/openapi.yaml")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("Content-Type")).contains("application/yaml"); + assertThat(resp.body()).isNotEmpty(); + } + } + + @Test + void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { + HttpHandler boom = + ex -> { + throw new RuntimeException("boom"); + }; + + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/boom", boom) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/boom")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + // Default exception handler maps unknown throwables to 500 with no body. + assertThat(resp.statusCode()).isEqualTo(500); + } + } + + @Test + void existingPublicConstructorStillWorks() { + try { + try (var s = + new OpenApiServer( + spec, jsonMapper(), Map.of(), defaultExceptionHandler(), 0)) { + assertThat(s.listenPort()).isGreaterThan(0); + } + } catch (IOException io) { + fail(io); + } + } +} +``` + +- [ ] **Step 2: Run integration tests** + +Run: `mvn -q verify -Dit.test=ExtraHandlersIT -DfailIfNoTests=false` +Expected: 4 tests pass. + +- [ ] **Step 3: Run full verify (catch regressions)** + +Run: `mvn -q verify` +Expected: All unit + integration tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/test/java/com/retailsvc/http/ExtraHandlersIT.java +git commit -m "test: integration coverage for extra handlers and builder" +``` + +--- + +## Task 6: README updates + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Read the current README example region** + +Run: `sed -n '70,110p' README.md` +Note the existing constructor invocation around line 86 — that block is the one to replace. + +- [ ] **Step 2: Replace the constructor example with builder example** + +In `README.md`, change the example using `new OpenApiServer(spec, mapper, handlers, Handlers.defaultExceptionHandler());` to: + +```java +var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(handlers) + .exceptionHandler(Handlers.defaultExceptionHandler()) + .build(); +``` + +- [ ] **Step 3: Add an "Extra (non-OpenAPI) handlers" subsection** + +Append a new subsection after the builder example (or in the most natural location near other usage docs): + +````markdown +### Extra (non-OpenAPI) handlers + +Mount handlers at arbitrary paths outside the OpenAPI spec — useful for liveness probes, +serving the spec document itself, or any other operational endpoint that should not be subject +to OpenAPI parameter / body validation. + +```java +var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(handlers) + .addHandler("/alive", Handlers.aliveHandler()) + .addHandler("/schemas/v1/openapi.yaml", + Handlers.specHandler("/schemas/v1/openapi.yaml")) + .build(); +``` + +Extra handlers bypass OpenAPI validation but are still wrapped in the configured +`ExceptionHandler`, so any uncaught exception is rendered using the same error envelope as +API routes. + +Built-in helpers: +- `Handlers.aliveHandler()` — 204 No Content on `GET`/`HEAD`, 405 otherwise. +- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type + inferred from extension). Throws `IllegalArgumentException` at construction if the + resource is missing. + +The original public constructors remain available for back-compat. +```` + +- [ ] **Step 4: Confirm pre-commit hooks pass** + +Run: `pre-commit run --files README.md` +Expected: all hooks pass (whitespace, editorconfig, etc.). + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: builder + extra-handlers usage in README" +``` + +--- + +## Task 7: Final verification + +- [ ] **Step 1: Full clean build** + +Run: `mvn -q clean verify` +Expected: BUILD SUCCESS. All unit and integration tests pass. + +- [ ] **Step 2: Pre-commit on the whole tree** + +Run: `pre-commit run --all-files` +Expected: all hooks pass. + +- [ ] **Step 3: Confirm git tree is clean** + +Run: `git status` +Expected: nothing to commit, working tree clean. diff --git a/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java b/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java new file mode 100644 index 0000000..37b9280 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java @@ -0,0 +1,58 @@ +package com.retailsvc.http.internal; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +/** + * Serves bytes loaded eagerly from a classpath resource. Content-Type is inferred from the file + * extension. Throws {@link IllegalArgumentException} if the resource is missing. + */ +public final class ClasspathResourceHandler implements HttpHandler { + + private final byte[] bytes; + private final String contentType; + + public ClasspathResourceHandler(String classpathResource) { + try (InputStream in = ClasspathResourceHandler.class.getResourceAsStream(classpathResource)) { + if (in == null) { + throw new IllegalArgumentException("classpath resource not found: " + classpathResource); + } + this.bytes = in.readAllBytes(); + } catch (IOException io) { + throw new IllegalArgumentException( + "failed reading classpath resource: " + classpathResource, io); + } + this.contentType = contentTypeFor(classpathResource); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try (exchange) { + exchange.getResponseHeaders().add("Content-Type", contentType); + if ("HEAD".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().add("Content-Length", String.valueOf(bytes.length)); + exchange.sendResponseHeaders(200, -1); + return; + } + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + } + } + + private static String contentTypeFor(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".txt")) { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java b/src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java new file mode 100644 index 0000000..ff41580 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java @@ -0,0 +1,35 @@ +package com.retailsvc.http.internal; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; + +/** + * Wraps a delegate handler so it answers only GET and HEAD. Other methods produce 405 with {@code + * Allow: GET, HEAD}. + */ +public final class MethodLimitedHandler implements HttpHandler { + + private static final String ALLOW = "GET, HEAD"; + + private final HttpHandler delegate; + + public MethodLimitedHandler(HttpHandler delegate) { + this.delegate = delegate; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + if ("GET".equals(method) || "HEAD".equals(method)) { + delegate.handle(exchange); + return; + } + try (exchange) { + exchange.getResponseHeaders().add("Allow", ALLOW); + exchange.sendResponseHeaders(HTTP_BAD_METHOD, -1); + } + } +} From 30e23e8c4a49045a035fc6e2ea23c7c09ccf5446 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 16:57:34 +0200 Subject: [PATCH 04/14] feat: Add Handlers.aliveHandler and Handlers.specHandler --- .../java/com/retailsvc/http/Handlers.java | 22 +++++ .../java/com/retailsvc/http/HandlersTest.java | 96 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/HandlersTest.java diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index bc73322..88d52f8 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -6,6 +6,8 @@ import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.nio.charset.StandardCharsets.UTF_8; +import com.retailsvc.http.internal.ClasspathResourceHandler; +import com.retailsvc.http.internal.MethodLimitedHandler; import com.retailsvc.http.internal.ProblemDetailRenderer; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -53,4 +55,24 @@ public static HttpHandler notFoundHandler() { } }; } + + /** Returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} otherwise. */ + public static HttpHandler aliveHandler() { + return new MethodLimitedHandler( + exchange -> { + try (exchange) { + exchange.sendResponseHeaders(204, -1); + } + }); + } + + /** + * Serves a classpath resource. Content-Type is inferred from the file extension. The resource is + * loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}. + * + * @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml} + */ + public static HttpHandler specHandler(String classpathResource) { + return new MethodLimitedHandler(new ClasspathResourceHandler(classpathResource)); + } } diff --git a/src/test/java/com/retailsvc/http/HandlersTest.java b/src/test/java/com/retailsvc/http/HandlersTest.java new file mode 100644 index 0000000..dd640f2 --- /dev/null +++ b/src/test/java/com/retailsvc/http/HandlersTest.java @@ -0,0 +1,96 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class HandlersTest { + + @Test + void aliveHandlerReturns204OnGet() throws IOException { + HttpExchange ex = newExchange("GET"); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(204, -1); + } + + @Test + void aliveHandlerReturns204OnHead() throws IOException { + HttpExchange ex = newExchange("HEAD"); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(204, -1); + } + + @Test + void aliveHandlerReturns405OnPost() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + Handlers.aliveHandler().handle(ex); + verify(ex).sendResponseHeaders(405, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } + + @Test + void specHandlerServesYamlWithInferredContentType() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.specHandler("/openapi.yaml").handle(ex); + + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/yaml"); + verify(ex) + .sendResponseHeaders( + org.mockito.ArgumentMatchers.eq(200), + org.mockito.ArgumentMatchers.longThat(n -> n > 0)); + assertThat(body.toByteArray()).isNotEmpty(); + } + + @Test + void specHandlerInfersJsonContentType() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + + Handlers.specHandler("/openapi.json").handle(ex); + + assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/json"); + } + + @Test + void specHandlerThrowsAtConstructionForMissingResource() { + assertThatThrownBy(() -> Handlers.specHandler("/does-not-exist.yaml")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("/does-not-exist.yaml"); + } + + @Test + void specHandlerReturns405OnPost() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + + Handlers.specHandler("/openapi.yaml").handle(ex); + + verify(ex).sendResponseHeaders(405, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } + + private static HttpExchange newExchange(String method) { + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getResponseHeaders()).thenReturn(new Headers()); + return ex; + } +} From 86f62c1966d0ac8ec0524ead8d478d9900b78f78 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 16:58:33 +0200 Subject: [PATCH 05/14] refactor: Use HttpURLConnection constants in alive/spec handlers --- src/main/java/com/retailsvc/http/Handlers.java | 3 ++- .../retailsvc/http/internal/ClasspathResourceHandler.java | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 88d52f8..9bdae9f 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -4,6 +4,7 @@ import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static java.nio.charset.StandardCharsets.UTF_8; import com.retailsvc.http.internal.ClasspathResourceHandler; @@ -61,7 +62,7 @@ public static HttpHandler aliveHandler() { return new MethodLimitedHandler( exchange -> { try (exchange) { - exchange.sendResponseHeaders(204, -1); + exchange.sendResponseHeaders(HTTP_NO_CONTENT, -1); } }); } diff --git a/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java b/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java index 37b9280..03791f8 100644 --- a/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java +++ b/src/main/java/com/retailsvc/http/internal/ClasspathResourceHandler.java @@ -1,5 +1,7 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_OK; + import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; @@ -34,10 +36,10 @@ public void handle(HttpExchange exchange) throws IOException { exchange.getResponseHeaders().add("Content-Type", contentType); if ("HEAD".equals(exchange.getRequestMethod())) { exchange.getResponseHeaders().add("Content-Length", String.valueOf(bytes.length)); - exchange.sendResponseHeaders(200, -1); + exchange.sendResponseHeaders(HTTP_OK, -1); return; } - exchange.sendResponseHeaders(200, bytes.length); + exchange.sendResponseHeaders(HTTP_OK, bytes.length); exchange.getResponseBody().write(bytes); } } From 56dd70087b96e1024ae9704c716e7383359b56c1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:00:32 +0200 Subject: [PATCH 06/14] feat: Add OpenApiServer.Builder with extra-handler support --- .../com/retailsvc/http/OpenApiServer.java | 86 ++++++++++++++++++- .../http/OpenApiServerBuilderTest.java | 81 +++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index aede3db..a723f5b 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -15,6 +15,7 @@ import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import org.slf4j.Logger; @@ -45,7 +46,7 @@ public OpenApiServer( Map handlers, ExceptionHandler exceptionHandler) throws IOException { - this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT); + this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of()); } /** @@ -63,6 +64,17 @@ public OpenApiServer( ExceptionHandler exceptionHandler, int port) throws IOException { + this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of()); + } + + OpenApiServer( + Spec spec, + JsonMapper jsonMapper, + Map handlers, + ExceptionHandler exceptionHandler, + int port, + Map extras) + throws IOException { requireNonNull(spec, "Spec must not be null"); requireNonNull(jsonMapper, "JsonMapper must not be null"); @@ -84,6 +96,12 @@ public OpenApiServer( ctx.getFilters().add(new RequestPreparationFilter(spec, router, validator, jsonMapper)); ctx.setHandler(new DispatchHandler(handlers)); + for (Map.Entry e : extras.entrySet()) { + HttpContext extraCtx = httpServer.createContext(e.getKey()); + extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler)); + extraCtx.setHandler(e.getValue()); + } + httpServer.createContext("/", Handlers.notFoundHandler()); httpServer.start(); @@ -100,4 +118,70 @@ public void close() { httpServer.stop(0); } } + + public static Builder builder() { + return new Builder(); + } + + /** Fluent builder for {@link OpenApiServer}. */ + public static final class Builder { + + private Spec spec; + private JsonMapper jsonMapper; + private Map handlers; + private ExceptionHandler exceptionHandler; + private int port = DEFAULT_PORT; + private final LinkedHashMap extras = new LinkedHashMap<>(); + + private Builder() {} + + public Builder spec(Spec spec) { + this.spec = spec; + return this; + } + + public Builder jsonMapper(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + return this; + } + + public Builder handlers(Map handlers) { + this.handlers = handlers; + return this; + } + + public Builder exceptionHandler(ExceptionHandler exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return this; + } + + public Builder port(int port) { + this.port = port; + return this; + } + + public Builder addHandler(String path, HttpHandler handler) { + requireNonNull(path, "path must not be null"); + requireNonNull(handler, "handler must not be null"); + if (extras.containsKey(path)) { + throw new IllegalStateException("duplicate extra handler path: " + path); + } + extras.put(path, handler); + return this; + } + + public OpenApiServer build() throws IOException { + requireNonNull(spec, "Spec must not be null"); + requireNonNull(jsonMapper, "JsonMapper must not be null"); + requireNonNull(handlers, "handlers must not be null"); + String basePath = Optional.ofNullable(spec.basePath()).orElse("/"); + for (String path : extras.keySet()) { + if (path.equals(basePath)) { + throw new IllegalStateException( + "extra handler path " + path + " conflicts with spec basePath " + basePath); + } + } + return new OpenApiServer(spec, jsonMapper, handlers, exceptionHandler, port, extras); + } + } } diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java new file mode 100644 index 0000000..e0ef59b --- /dev/null +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -0,0 +1,81 @@ +package com.retailsvc.http; + +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.retailsvc.http.spec.Spec; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class OpenApiServerBuilderTest { + + private final Spec spec = testSpec(); + private final JsonMapper jsonMapper = body -> new java.util.HashMap(); + + @Test + void buildsWithRequiredFieldsOnly() { + assertDoesNotThrow( + () -> { + try (var _ = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .port(0) + .build()) { + // close on exit + } + }); + } + + @Test + void rejectsDuplicateExtraPathOnSecondAddHandler() { + OpenApiServer.Builder b = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .addHandler("/alive", Handlers.aliveHandler()); + + assertThatThrownBy(() -> b.addHandler("/alive", Handlers.aliveHandler())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("/alive"); + } + + @Test + void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { + // testSpec() uses "/api" as the basePath (servers[0].url = http://localhost:8080/api). + assertThatThrownBy( + () -> + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .addHandler("/api", Handlers.aliveHandler()) + .port(0) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("/api"); + } + + @Test + void rejectsNullSpec() { + assertThatThrownBy( + () -> + OpenApiServer.builder().jsonMapper(jsonMapper).handlers(emptyMap()).port(0).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Spec"); + } + + private static Spec testSpec() { + Map raw = + Map.of( + "openapi", "3.1.0", + "info", Map.of("title", "Test API", "version", "1.0"), + "servers", List.of(Map.of("url", "http://localhost:8080/api")), + "paths", emptyMap()); + return Spec.from(raw); + } +} From d36982a8ba9b744406bb5a47dbb8d70d3d16fd46 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:02:11 +0200 Subject: [PATCH 07/14] fix: Use responseLength=-1 for empty-body status responses sendResponseHeaders(code, 0) triggers chunked transfer encoding for empty bodies; -1 is correct for status-only responses with no body. Affects notFoundHandler and the NotFound/MethodNotAllowed/default branches of defaultExceptionHandler. --- src/main/java/com/retailsvc/http/Handlers.java | 8 ++++---- .../com/retailsvc/http/HandlersDefaultExceptionTest.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 9bdae9f..45ad17b 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -32,15 +32,15 @@ public static ExceptionHandler defaultExceptionHandler() { exchange.sendResponseHeaders(HTTP_BAD_REQUEST, body.length); exchange.getResponseBody().write(body); } - case NotFoundException _ -> exchange.sendResponseHeaders(HTTP_NOT_FOUND, 0); + case NotFoundException _ -> exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1); case MethodNotAllowedException mna -> { String allow = mna.allowed().stream().map(Enum::name).collect(Collectors.joining(", ")); exchange.getResponseHeaders().add("Allow", allow); - exchange.sendResponseHeaders(HTTP_BAD_METHOD, 0); + exchange.sendResponseHeaders(HTTP_BAD_METHOD, -1); } default -> { LOG.error("Unhandled exception in handler", t); - exchange.sendResponseHeaders(HTTP_INTERNAL_ERROR, 0); + exchange.sendResponseHeaders(HTTP_INTERNAL_ERROR, -1); } } } catch (IOException io) { @@ -52,7 +52,7 @@ public static ExceptionHandler defaultExceptionHandler() { public static HttpHandler notFoundHandler() { return exchange -> { try (exchange) { - exchange.sendResponseHeaders(HTTP_NOT_FOUND, 0); + exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1); } }; } diff --git a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java index c9e29d7..8bac1d0 100644 --- a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java +++ b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java @@ -40,7 +40,7 @@ void validationExceptionRendersProblem() throws Exception { void notFoundReturns404() throws Exception { HttpExchange ex = newExchange(new ByteArrayOutputStream()); Handlers.defaultExceptionHandler().handle(ex, new NotFoundException("GET /x")); - Mockito.verify(ex).sendResponseHeaders(404, 0); + Mockito.verify(ex).sendResponseHeaders(404, -1); } @Test @@ -48,7 +48,7 @@ void methodNotAllowedReturns405WithAllowHeader() throws Exception { HttpExchange ex = newExchange(new ByteArrayOutputStream()); Handlers.defaultExceptionHandler() .handle(ex, new MethodNotAllowedException(Set.of(HttpMethod.GET, HttpMethod.POST))); - Mockito.verify(ex).sendResponseHeaders(405, 0); + Mockito.verify(ex).sendResponseHeaders(405, -1); assertThat(ex.getResponseHeaders().getFirst("Allow")).contains("GET").contains("POST"); } } From 6f29b49600bbf2fbf0a7abc870394ff81c54b469 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:03:43 +0200 Subject: [PATCH 08/14] test: Integration coverage for extra handlers and builder --- .../com/retailsvc/http/ExtraHandlersIT.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/ExtraHandlersIT.java diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java new file mode 100644 index 0000000..9be1629 --- /dev/null +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -0,0 +1,107 @@ +package com.retailsvc.http; + +import static com.retailsvc.http.Handlers.defaultExceptionHandler; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExtraHandlersIT extends ServerBaseTest { + + @Test + void aliveExtraReturns204AndBypassesValidation() throws Exception { + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/alive", Handlers.aliveHandler()) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/alive")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(204); + assertThat(resp.body()).isEmpty(); + } + } + + @Test + void specHandlerServesClasspathResource() throws Exception { + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/openapi.yaml", Handlers.specHandler("/openapi.yaml")) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/openapi.yaml")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("Content-Type")).contains("application/yaml"); + assertThat(resp.body()).isNotEmpty(); + } + } + + @Test + void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { + HttpHandler boom = + ex -> { + throw new RuntimeException("boom"); + }; + + try (var s = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper()) + .handlers(Map.of()) + .exceptionHandler(defaultExceptionHandler()) + .port(0) + .addHandler("/boom", boom) + .build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/boom")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(500); + } + } + + @Test + void existingPublicConstructorStillWorks() { + try { + try (var s = new OpenApiServer(spec, jsonMapper(), Map.of(), defaultExceptionHandler(), 0)) { + assertThat(s.listenPort()).isGreaterThan(0); + } + } catch (IOException io) { + fail(io); + } + } +} From 6fae772cffd3f07040ff4ea460bbdfb7df839f7a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:05:14 +0200 Subject: [PATCH 09/14] docs: Builder and extra-handlers usage in README --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9299518..a283b6a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,12 @@ public class YourServerLauncher { handlers.put("get-data", new GetDataHandler()); handlers.put("post-data", new PostDataHandler()); - new OpenApiServer(spec, mapper, handlers, Handlers.defaultExceptionHandler()); + var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(handlers) + .exceptionHandler(Handlers.defaultExceptionHandler()) + .build(); } } ``` @@ -95,6 +100,35 @@ Map raw = new Yaml().load(Files.newInputStream(Path.of("openapi. ``` The rest is identical. +### Extra (non-OpenAPI) handlers + +Mount handlers at arbitrary paths outside the OpenAPI spec — useful for liveness probes, +serving the spec document itself, or any other operational endpoint that should not be subject +to OpenAPI parameter / body validation. + +``` java +var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(handlers) + .addHandler("/alive", Handlers.aliveHandler()) + .addHandler("/schemas/v1/openapi.yaml", + Handlers.specHandler("/schemas/v1/openapi.yaml")) + .build(); +``` + +Extra handlers bypass OpenAPI validation but are still wrapped in the configured +`ExceptionHandler`, so any uncaught exception is rendered using the same error envelope as +API routes. + +Built-in helpers: +- `Handlers.aliveHandler()` — 204 No Content on `GET`/`HEAD`, 405 otherwise. +- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type + inferred from extension). Throws `IllegalArgumentException` at construction if the resource + is missing. + +The original public constructors remain available for back-compat. + ## Features - OpenAPI specification support - Automatic request body parsing for JSON arrays and objects From 6cb8a0b18ad63fa15e95824521577d2497a51d8f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:07:13 +0200 Subject: [PATCH 10/14] style: Apply Google Java Formatter to GetDataHandler --- .../java/com/retailsvc/http/start/GetDataHandler.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/retailsvc/http/start/GetDataHandler.java b/src/test/java/com/retailsvc/http/start/GetDataHandler.java index 7ead5bc..fe4f13d 100644 --- a/src/test/java/com/retailsvc/http/start/GetDataHandler.java +++ b/src/test/java/com/retailsvc/http/start/GetDataHandler.java @@ -17,10 +17,13 @@ public void handle(HttpExchange exchange) throws IOException { LOG.debug("GET /data"); try (exchange) { - byte[] bytes = """ - { - "id": "some-id" - }""".getBytes(); + byte[] bytes = + """ + { + "id": "some-id" + }\ + """ + .getBytes(); try (var os = exchange.getResponseBody()) { var responseHeaders = exchange.getResponseHeaders(); responseHeaders.add("content-type", "application/json"); From 9441a5e7fcb46a30b799c1c0e87be78d9df288aa Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:47:16 +0200 Subject: [PATCH 11/14] refactor: Hoist builder setup out of assertThatThrownBy lambdas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarQube java:S5778 — each throwing lambda should contain only one invocation that could throw a runtime exception. --- .../http/OpenApiServerBuilderTest.java | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java index e0ef59b..379e8a9 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.retailsvc.http.spec.Spec; +import com.sun.net.httpserver.HttpHandler; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -32,14 +33,15 @@ void buildsWithRequiredFieldsOnly() { @Test void rejectsDuplicateExtraPathOnSecondAddHandler() { + HttpHandler duplicate = Handlers.aliveHandler(); OpenApiServer.Builder b = OpenApiServer.builder() .spec(spec) .jsonMapper(jsonMapper) .handlers(emptyMap()) - .addHandler("/alive", Handlers.aliveHandler()); + .addHandler("/alive", duplicate); - assertThatThrownBy(() -> b.addHandler("/alive", Handlers.aliveHandler())) + assertThatThrownBy(() -> b.addHandler("/alive", duplicate)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("/alive"); } @@ -47,24 +49,25 @@ void rejectsDuplicateExtraPathOnSecondAddHandler() { @Test void rejectsExtraPathEqualToSpecBasePathAtBuildTime() { // testSpec() uses "/api" as the basePath (servers[0].url = http://localhost:8080/api). - assertThatThrownBy( - () -> - OpenApiServer.builder() - .spec(spec) - .jsonMapper(jsonMapper) - .handlers(emptyMap()) - .addHandler("/api", Handlers.aliveHandler()) - .port(0) - .build()) + OpenApiServer.Builder b = + OpenApiServer.builder() + .spec(spec) + .jsonMapper(jsonMapper) + .handlers(emptyMap()) + .addHandler("/api", Handlers.aliveHandler()) + .port(0); + + assertThatThrownBy(b::build) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("/api"); } @Test void rejectsNullSpec() { - assertThatThrownBy( - () -> - OpenApiServer.builder().jsonMapper(jsonMapper).handlers(emptyMap()).port(0).build()) + OpenApiServer.Builder b = + OpenApiServer.builder().jsonMapper(jsonMapper).handlers(emptyMap()).port(0); + + assertThatThrownBy(b::build) .isInstanceOf(NullPointerException.class) .hasMessageContaining("Spec"); } From f8e04f22cc8b90fec050aa330d1f46615466a6e0 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:49:50 +0200 Subject: [PATCH 12/14] test: Direct unit tests for ClasspathResourceHandler --- .../ClasspathResourceHandlerTest.java | 116 ++++++++++++++++++ src/test/resources/sample.bin | 1 + src/test/resources/sample.txt | 1 + 3 files changed, 118 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java create mode 100644 src/test/resources/sample.bin create mode 100644 src/test/resources/sample.txt diff --git a/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java b/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java new file mode 100644 index 0000000..f786871 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java @@ -0,0 +1,116 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.longThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import org.junit.jupiter.api.Test; + +class ClasspathResourceHandlerTest { + + @Test + void getServesBytesVerbatim() throws IOException { + byte[] expected = readResource("/sample.txt"); + HttpExchange ex = newExchange("GET"); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + new ClasspathResourceHandler("/sample.txt").handle(ex); + + verify(ex).sendResponseHeaders(eq(200), eq((long) expected.length)); + assertThat(body.toByteArray()).isEqualTo(expected); + } + + @Test + void headSendsContentLengthHeaderWithoutBody() throws IOException { + byte[] expected = readResource("/sample.txt"); + HttpExchange ex = newExchange("HEAD"); + Headers responseHeaders = new Headers(); + when(ex.getResponseHeaders()).thenReturn(responseHeaders); + + new ClasspathResourceHandler("/sample.txt").handle(ex); + + verify(ex).sendResponseHeaders(eq(200), eq(-1L)); + assertThat(responseHeaders.getFirst("Content-Length")) + .isEqualTo(String.valueOf(expected.length)); + } + + @Test + void infersApplicationJsonForJsonExtension() throws IOException { + assertThat(contentTypeFor("/openapi.json")).isEqualTo("application/json"); + } + + @Test + void infersApplicationYamlForYamlExtension() throws IOException { + assertThat(contentTypeFor("/openapi.yaml")).isEqualTo("application/yaml"); + } + + @Test + void infersTextPlainForTxtExtension() throws IOException { + assertThat(contentTypeFor("/sample.txt")).isEqualTo("text/plain; charset=utf-8"); + } + + @Test + void fallsBackToOctetStreamForUnknownExtension() throws IOException { + assertThat(contentTypeFor("/sample.bin")).isEqualTo("application/octet-stream"); + } + + @Test + void missingResourceThrowsIllegalArgumentExceptionWithPathInMessage() { + assertThatThrownBy(() -> new ClasspathResourceHandler("/does-not-exist.json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("/does-not-exist.json"); + } + + @Test + void resourceIsLoadedEagerlyAtConstruction() { + // If the resource were loaded lazily, construction would succeed and the handle() + // call would fail. Construction itself must fail. + assertThatThrownBy(() -> new ClasspathResourceHandler("/missing.txt")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void contentLengthIsSetForGetRequests() throws IOException { + HttpExchange ex = newExchange("GET"); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + + new ClasspathResourceHandler("/sample.txt").handle(ex); + + verify(ex).sendResponseHeaders(eq(200), longThat(n -> n > 0)); + } + + private static String contentTypeFor(String resource) throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + new ClasspathResourceHandler(resource).handle(ex); + return headers.getFirst("Content-Type"); + } + + private static HttpExchange newExchange(String method) { + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getResponseHeaders()).thenReturn(new Headers()); + return ex; + } + + private static byte[] readResource(String path) throws IOException { + try (InputStream in = ClasspathResourceHandlerTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IOException("missing fixture: " + path); + } + return in.readAllBytes(); + } + } +} diff --git a/src/test/resources/sample.bin b/src/test/resources/sample.bin new file mode 100644 index 0000000..3e53b66 --- /dev/null +++ b/src/test/resources/sample.bin @@ -0,0 +1 @@ +binary-payload diff --git a/src/test/resources/sample.txt b/src/test/resources/sample.txt new file mode 100644 index 0000000..77ff494 --- /dev/null +++ b/src/test/resources/sample.txt @@ -0,0 +1 @@ +hello text From d7186f712e505c493d0ec976ffc25731ff2fef28 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:52:03 +0200 Subject: [PATCH 13/14] build: Drop deprecated parameter from dependency-plugin maven-dependency-plugin 3.10.0 deprecates the POM parameter in favour of the -q CLI flag; removing it silences the build warning. --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index e83be26..01bede5 100644 --- a/pom.xml +++ b/pom.xml @@ -128,7 +128,6 @@ false ${project.build.directory}/lib - true runtime From 84da7732f1147ac0af292527647d4e8a73dac52f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Tue, 12 May 2026 17:53:04 +0200 Subject: [PATCH 14/14] refactor: Drop redundant eq() matchers in ClasspathResourceHandlerTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarQube java:S6068 — when every verify() argument is wrapped in eq(), pass raw values instead. The eq() import is retained for the one call that mixes eq() with longThat(), where matchers must be used uniformly. --- .../retailsvc/http/internal/ClasspathResourceHandlerTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java b/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java index f786871..1e786a5 100644 --- a/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/ClasspathResourceHandlerTest.java @@ -26,7 +26,7 @@ void getServesBytesVerbatim() throws IOException { new ClasspathResourceHandler("/sample.txt").handle(ex); - verify(ex).sendResponseHeaders(eq(200), eq((long) expected.length)); + verify(ex).sendResponseHeaders(200, expected.length); assertThat(body.toByteArray()).isEqualTo(expected); } @@ -39,7 +39,7 @@ void headSendsContentLengthHeaderWithoutBody() throws IOException { new ClasspathResourceHandler("/sample.txt").handle(ex); - verify(ex).sendResponseHeaders(eq(200), eq(-1L)); + verify(ex).sendResponseHeaders(200, -1); assertThat(responseHeaders.getFirst("Content-Length")) .isEqualTo(String.valueOf(expected.length)); }