From 71f8fcc0646a8ef640b2707a7fbeb170bde52dcf Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 08:41:13 +0200 Subject: [PATCH] feat: Fail fast at boot when handlers and spec disagree The default exception handler routed MissingOperationHandlerException to a bare 500 at request time, so an OpenAPI operation declared in the spec but missing from the handler map only surfaced when a client hit it. ZAP picked this up against the example launcher and flagged seven Server Error / Application Error Disclosure findings. Validate handler/spec wiring at OpenApiServer.Builder.build() and throw IllegalStateException with the offending operationIds when: - a spec operationId has no registered handler, or - a handler is registered for an operationId not in the spec. Once the boot check is in place the dispatch-time null check, the MissingOperationHandlerException class, and its unit test are unreachable, so they are deleted. The example ServerLauncher now registers stub handlers for every operation in src/test/resources/openapi.json so the demo (and the ZAP scan) covers the full surface. ServerBaseTest grows a stubAllHandlers helper so the existing tests can keep registering the subset they care about and pick up stubs for the rest. Re-running ZAP against the updated launcher: no Server Error / no Application Error Disclosure findings; only header hardening warnings remain. --- CLAUDE.md | 2 +- .../MissingOperationHandlerException.java | 7 -- .../com/retailsvc/http/OpenApiServer.java | 21 ++++ .../http/internal/DispatchHandler.java | 4 - .../retailsvc/http/AfterResponseHookIT.java | 104 ++++++++---------- .../http/DecoratorAndInterceptorIT.java | 8 +- .../com/retailsvc/http/ExtraHandlersIT.java | 18 ++- .../http/HandlerBootValidationTest.java | 89 +++++++++++++++ .../com/retailsvc/http/OpenApiServerIT.java | 21 ---- .../http/RequestResponseGatewayTest.java | 10 +- .../java/com/retailsvc/http/SecurityIT.java | 13 ++- .../com/retailsvc/http/ServerBaseTest.java | 32 +++++- .../http/TypeMapperRegistrationTest.java | 6 +- .../http/internal/DispatchHandlerTest.java | 18 --- .../retailsvc/http/start/ServerLauncher.java | 9 ++ 15 files changed, 226 insertions(+), 136 deletions(-) delete mode 100644 src/main/java/com/retailsvc/http/MissingOperationHandlerException.java create mode 100644 src/test/java/com/retailsvc/http/HandlerBootValidationTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 03026cb..70b0896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenA 3. Three filters run in order on every request: - `ExceptionFilter` — wraps the chain; delegates uncaught exceptions to the user-supplied `ExceptionHandler` (default in `Handlers`). - `RequestPreparationFilter` — reads the raw request body, stashes it as an exchange attribute, runs OpenAPI parameter + body validation via `DefaultValidator`, and stores the resolved `operationId` on the exchange. - - `DispatchHandler` — looks up the `HttpHandler` registered for that `operationId` in the user-supplied map and invokes it. Missing handler → `MissingOperationHandlerException`. + - `DispatchHandler` — looks up the `HttpHandler` registered for that `operationId` in the user-supplied map and invokes it. Handler coverage is verified at boot, so the lookup never returns `null`. Key abstractions: diff --git a/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java b/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java deleted file mode 100644 index 7b4f237..0000000 --- a/src/main/java/com/retailsvc/http/MissingOperationHandlerException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.retailsvc.http; - -public final class MissingOperationHandlerException extends RuntimeException { - public MissingOperationHandlerException(String operationId) { - super("no handler registered for operationId=" + operationId); - } -} diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 46352d2..b778d17 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -339,6 +340,7 @@ public OpenApiServer build() throws IOException { if (!externalAuth) { validateSecurityWiring(spec, securityValidators); } + validateHandlerWiring(spec, handlers); Map resolved = resolveBodyMappers(bodyMappers); ExceptionHandler effectiveExceptionHandler = exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler(); @@ -356,6 +358,25 @@ public OpenApiServer build() throws IOException { spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); } + private static void validateHandlerWiring(Spec spec, Map handlers) { + Set specOps = new TreeSet<>(); + for (Operation op : spec.operations()) { + specOps.add(op.operationId()); + } + Set missing = new TreeSet<>(specOps); + missing.removeAll(handlers.keySet()); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "no handler registered for spec operationId(s): " + missing); + } + Set unknown = new TreeSet<>(handlers.keySet()); + unknown.removeAll(specOps); + if (!unknown.isEmpty()) { + throw new IllegalStateException( + "handler registered for unknown operationId(s) not in spec: " + unknown); + } + } + private static void validateSecurityWiring(Spec spec, Map validators) { Set referenced = new LinkedHashSet<>(); for (Operation op : spec.operations()) { diff --git a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java index aeb9bee..fceab90 100644 --- a/src/main/java/com/retailsvc/http/internal/DispatchHandler.java +++ b/src/main/java/com/retailsvc/http/internal/DispatchHandler.java @@ -1,6 +1,5 @@ package com.retailsvc.http.internal; -import com.retailsvc.http.MissingOperationHandlerException; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import com.retailsvc.http.RequestInterceptor; @@ -37,9 +36,6 @@ public DispatchHandler( public void handle(HttpExchange exchange) throws IOException { Request request = CURRENT.get(); RequestHandler handler = handlers.get(request.operationId()); - if (handler == null) { - throw new MissingOperationHandlerException(request.operationId()); - } Response response = invoke(0, request, handler); for (ResponseDecorator decorator : decorators) { response = decorator.decorate(request, response); diff --git a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java index 7f35874..bef6d6a 100644 --- a/src/test/java/com/retailsvc/http/AfterResponseHookIT.java +++ b/src/test/java/com/retailsvc/http/AfterResponseHookIT.java @@ -1,5 +1,6 @@ package com.retailsvc.http; +import static com.retailsvc.http.ServerBaseTest.stubAllHandlers; import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NO_CONTENT; @@ -31,6 +32,8 @@ class AfterResponseHookIT { private static final String OK_PATH = "/api/v1/data"; private static final String NOT_FOUND_PATH = "/api/v1/does-not-exist"; + private static final Spec SPEC = loadSpec(); + private static Spec loadSpec() { Gson gson = new Gson(); try (InputStream in = AfterResponseHookIT.class.getResourceAsStream("/openapi.json")) { @@ -45,7 +48,7 @@ private static Spec loadSpec() { private static OpenApiServer.Builder baseBuilder() { return OpenApiServer.builder() - .spec(loadSpec()) + .spec(SPEC) .port(0) .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) @@ -64,7 +67,9 @@ void globalHookFiresAfterSuccessfulResponse() throws Exception { try (OpenApiServer server = baseBuilder() - .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .handlers( + stubAllHandlers( + SPEC, Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT)))) .afterResponseHook( (req, resp) -> { capturedRequest.set(req); @@ -96,21 +101,23 @@ void perRequestRunnablesFireInOrder() throws Exception { try (OpenApiServer server = baseBuilder() .handlers( - Map.of( - OK_OPERATION_ID, - req -> { - req.afterResponse( - () -> { - log.add("first"); - latch.countDown(); - }); - req.afterResponse( - () -> { - log.add("second"); - latch.countDown(); - }); - return Response.status(HTTP_NO_CONTENT); - })) + stubAllHandlers( + SPEC, + Map.of( + OK_OPERATION_ID, + req -> { + req.afterResponse( + () -> { + log.add("first"); + latch.countDown(); + }); + req.afterResponse( + () -> { + log.add("second"); + latch.countDown(); + }); + return Response.status(HTTP_NO_CONTENT); + }))) .build()) { HttpClient.newHttpClient() @@ -130,7 +137,9 @@ void hookExceptionDoesNotAffectClientOrOtherHooks() throws Exception { try (OpenApiServer server = baseBuilder() - .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .handlers( + stubAllHandlers( + SPEC, Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT)))) .afterResponseHook( (req, resp) -> { throw new RuntimeException("boom"); @@ -162,11 +171,13 @@ void hookFiresOnHandlerException() throws Exception { try (OpenApiServer server = baseBuilder() .handlers( - Map.of( - OK_OPERATION_ID, - req -> { - throw new RuntimeException("kapow"); - })) + stubAllHandlers( + SPEC, + Map.of( + OK_OPERATION_ID, + req -> { + throw new RuntimeException("kapow"); + }))) .afterResponseHook( (req, resp) -> { capturedResponse.set(resp); @@ -194,7 +205,9 @@ void preRequestFailureSkipsHooks() throws Exception { try (OpenApiServer server = baseBuilder() - .handlers(Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT))) + .handlers( + stubAllHandlers( + SPEC, Map.of(OK_OPERATION_ID, req -> Response.status(HTTP_NO_CONTENT)))) .afterResponseHook((req, resp) -> log.add("fired")) .build()) { @@ -219,12 +232,14 @@ void hookSeesScopedRequestAndSameThreadAsHandler() throws Exception { try (OpenApiServer server = baseBuilder() .handlers( - Map.of( - OK_OPERATION_ID, - req -> { - handlerThread.set(Thread.currentThread()); - return Response.status(HTTP_NO_CONTENT); - })) + stubAllHandlers( + SPEC, + Map.of( + OK_OPERATION_ID, + req -> { + handlerThread.set(Thread.currentThread()); + return Response.status(HTTP_NO_CONTENT); + }))) .afterResponseHook( (req, resp) -> { hookScopedRequest.set(DispatchHandler.CURRENT.get()); @@ -244,33 +259,4 @@ void hookSeesScopedRequestAndSameThreadAsHandler() throws Exception { assertThat(hookThread.get()).isSameAs(handlerThread.get()); } } - - @Test - void hookFiresWhenHandlerIsMissing() throws Exception { - CountDownLatch latch = new CountDownLatch(1); - AtomicReference capturedResponse = new AtomicReference<>(); - - try (OpenApiServer server = - baseBuilder() - .handlers(Map.of()) // no handler registered for OK_OPERATION_ID - .afterResponseHook( - (req, resp) -> { - capturedResponse.set(resp); - latch.countDown(); - }) - .build()) { - - HttpResponse resp = - HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder(uri(server, OK_PATH)).GET().build(), - BodyHandlers.discarding()); - - assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(resp.statusCode()).isEqualTo(HTTP_INTERNAL_ERROR); - assertThat(capturedResponse.get()).isNotNull(); - assertThat(capturedResponse.get().status()).isEqualTo(HTTP_INTERNAL_ERROR); - assertThat(capturedResponse.get().body()).isNull(); - } - } } diff --git a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java index 75f10f4..ac42437 100644 --- a/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java +++ b/src/test/java/com/retailsvc/http/DecoratorAndInterceptorIT.java @@ -23,7 +23,7 @@ void responseDecoratorAddsHeadersOnEveryResponse() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", ok, "post-data", ok)) + .handlers(stubAllHandlers(Map.of("get-data", ok, "post-data", ok))) .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", "decorator-cid")) .responseDecorator((req, resp) -> resp.withHeader("X-Op", req.operationId())) .port(0) @@ -42,7 +42,7 @@ void decoratorHeaderOverridesHandlerHeader() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", ok, "post-data", ok)) + .handlers(stubAllHandlers(Map.of("get-data", ok, "post-data", ok))) .responseDecorator((req, resp) -> resp.withHeader("X-Op", "decorator-wins")) .port(0) .build(); @@ -58,7 +58,7 @@ void interceptorBindsScopedValueVisibleToHandler() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", echoTenant, "post-data", echoTenant)) + .handlers(stubAllHandlers(Map.of("get-data", echoTenant, "post-data", echoTenant))) .interceptor((request, next) -> ScopedValue.where(TENANT, "acme").call(next::proceed)) .port(0) .build(); @@ -77,7 +77,7 @@ void interceptorsRunInRegistrationOrder() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", ok, "post-data", ok)) + .handlers(stubAllHandlers(Map.of("get-data", ok, "post-data", ok))) .interceptor( (request, next) -> { trace.add("outer-before"); diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index 4eea867..e030321 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -15,7 +15,7 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception { try (var s = newBuilder() .spec(spec) - .handlers(Map.of()) + .handlers(stubAllHandlers(Map.of())) .port(0) .extraRoute("/alive", Handlers.aliveHandler()) .build(); @@ -38,7 +38,7 @@ void resourceHandlerServesClasspathResource() throws Exception { try (var s = newBuilder() .spec(spec) - .handlers(Map.of()) + .handlers(stubAllHandlers(Map.of())) .port(0) .extraRoute("/openapi.yaml", Handlers.resourceHandler("/openapi.yaml")) .build(); @@ -63,7 +63,12 @@ void textHtmlResponseIsSerializedByDefaultMapper() throws Exception { req -> Response.of(200, "

hi

").withContentType("text/html; charset=UTF-8"); try (var s = - newBuilder().spec(spec).handlers(Map.of()).port(0).extraRoute("/page", html).build(); + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of())) + .port(0) + .extraRoute("/page", html) + .build(); var client = httpClient()) { var req = @@ -88,7 +93,12 @@ void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { }; try (var s = - newBuilder().spec(spec).handlers(Map.of()).port(0).extraRoute("/boom", boom).build(); + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of())) + .port(0) + .extraRoute("/boom", boom) + .build(); var client = httpClient()) { var req = diff --git a/src/test/java/com/retailsvc/http/HandlerBootValidationTest.java b/src/test/java/com/retailsvc/http/HandlerBootValidationTest.java new file mode 100644 index 0000000..13fcaf6 --- /dev/null +++ b/src/test/java/com/retailsvc/http/HandlerBootValidationTest.java @@ -0,0 +1,89 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.retailsvc.http.spec.Spec; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class HandlerBootValidationTest { + + private static Map twoOpSpec() { + return Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "T", "version", "1"), + "servers", + List.of(Map.of("url", "/v1")), + "paths", + Map.of( + "/a", + Map.of( + "get", + Map.of( + "operationId", + "getA", + "responses", + Map.of("200", Map.of("description", "ok")))), + "/b", + Map.of( + "get", + Map.of( + "operationId", + "getB", + "responses", + Map.of("200", Map.of("description", "ok")))))); + } + + @Test + void missingHandlerForSpecOperationThrows() { + Spec spec = Spec.from(twoOpSpec()); + OpenApiServer.Builder b = + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("getA", req -> Response.ok(Map.of()))) + .port(0); + + assertThatThrownBy(b::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("getB"); + } + + @Test + void handlerForUnknownOperationThrows() { + Spec spec = Spec.from(twoOpSpec()); + OpenApiServer.Builder b = + OpenApiServer.builder() + .spec(spec) + .handlers( + Map.of( + "getA", req -> Response.ok(Map.of()), + "getB", req -> Response.ok(Map.of()), + "ghost", req -> Response.ok(Map.of()))) + .port(0); + + assertThatThrownBy(b::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ghost"); + } + + @Test + void exactMatchSucceeds() throws Exception { + Spec spec = Spec.from(twoOpSpec()); + OpenApiServer server = + OpenApiServer.builder() + .spec(spec) + .handlers( + Map.of( + "getA", req -> Response.ok(Map.of()), + "getB", req -> Response.ok(Map.of()))) + .port(0) + .build(); + + assertThat(server).isNotNull(); + server.close(); + } +} diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index 6b6e5e7..1d6118b 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -379,27 +379,6 @@ void getPathParamsShouldReturnBadRequestOnBadFormatPathParam() { fail(e); } } - - @Test - void getPathParamsShouldReturnInternalErrorOnMissingHandler() { - try (var server = newServer(Map.of("not-a-valid-operation-id", new EchoHandler())); - var client = httpClient()) { - - var pathWithParams = path + "/1234567890/Justin/Case"; - var request = newRequest(server, pathWithParams, "GET", noBody()); - - var response = client.send(request, BodyHandlers.ofString()); - var statusCode = response.statusCode(); - - assertThat(statusCode).isEqualTo(500); - - } catch (IOException e) { - fail(e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - fail(e); - } - } } @Nested diff --git a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java index dd77805..b1f6c09 100644 --- a/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java +++ b/src/test/java/com/retailsvc/http/RequestResponseGatewayTest.java @@ -22,7 +22,7 @@ void respondJsonWritesBodyAndContentType() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", echo, "post-data", echo)) + .handlers(stubAllHandlers(Map.of("get-data", echo, "post-data", echo))) .port(0) .build(); HttpClient client = @@ -47,7 +47,11 @@ void respondJsonWritesBodyAndContentType() throws Exception { void respondEmptyUses204Style() throws Exception { RequestHandler ok = req -> Response.status(HTTP_NO_CONTENT); server = - newBuilder().spec(spec).handlers(Map.of("get-data", ok, "post-data", ok)).port(0).build(); + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of("get-data", ok, "post-data", ok))) + .port(0) + .build(); var resp = HttpClient.newHttpClient() .send( @@ -76,7 +80,7 @@ void respondStreamUsesChunkedEncoding() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", streamer, "post-data", streamer)) + .handlers(stubAllHandlers(Map.of("get-data", streamer, "post-data", streamer))) .port(0) .build(); var resp = diff --git a/src/test/java/com/retailsvc/http/SecurityIT.java b/src/test/java/com/retailsvc/http/SecurityIT.java index 86063b0..f5923da 100644 --- a/src/test/java/com/retailsvc/http/SecurityIT.java +++ b/src/test/java/com/retailsvc/http/SecurityIT.java @@ -137,11 +137,12 @@ void externalAuthBypassesAllChecks() throws Exception { assertThat(r.statusCode()).isEqualTo(200); } - private static Map defaultHandlers() { - return Map.of( - "secureApiKey", req -> Response.ok("{\"ok\":true}"), - "secureBearer", req -> Response.ok("{\"ok\":true}"), - "secureBasic", req -> Response.ok("{\"ok\":true}"), - "secureOpen", req -> Response.ok("{\"ok\":true}")); + private Map defaultHandlers() { + return stubAllHandlers( + Map.of( + "secureApiKey", req -> Response.ok("{\"ok\":true}"), + "secureBearer", req -> Response.ok("{\"ok\":true}"), + "secureBasic", req -> Response.ok("{\"ok\":true}"), + "secureOpen", req -> Response.ok("{\"ok\":true}"))); } } diff --git a/src/test/java/com/retailsvc/http/ServerBaseTest.java b/src/test/java/com/retailsvc/http/ServerBaseTest.java index 8c45eae..618a889 100644 --- a/src/test/java/com/retailsvc/http/ServerBaseTest.java +++ b/src/test/java/com/retailsvc/http/ServerBaseTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.fail; import com.google.gson.Gson; +import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.Spec; import java.io.InputStream; import java.net.URI; @@ -52,16 +53,11 @@ protected OpenApiServer.Builder newBuilder() { } protected OpenApiServer newServer(Map handlers) { - Map all = new HashMap<>(handlers); - all.putIfAbsent("secureApiKey", req -> Response.status(200)); - all.putIfAbsent("secureBearer", req -> Response.status(200)); - all.putIfAbsent("secureBasic", req -> Response.status(200)); - all.putIfAbsent("secureOpen", req -> Response.status(200)); try { server = OpenApiServer.builder() .spec(spec) - .handlers(all) + .handlers(stubAllHandlers(handlers)) .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) .securityValidator("basicAuth", (req, cred) -> Optional.empty()) @@ -74,6 +70,30 @@ protected OpenApiServer newServer(Map handlers) { return null; } + /** + * Returns a handler map covering every operationId declared in {@link #spec}, with the supplied + * {@code overrides} taking precedence. Tests that exercise only a subset of operations can + * register handlers for just those, and remaining spec operations get a stub returning 200 so the + * fail-fast boot validation in {@link OpenApiServer.Builder} stays satisfied. + */ + protected Map stubAllHandlers(Map overrides) { + return stubAllHandlers(spec, overrides); + } + + /** + * Static variant of {@link #stubAllHandlers(Map)} for tests that hold their own {@link Spec} + * instance and do not extend {@link ServerBaseTest} as a fixture. + */ + static Map stubAllHandlers( + Spec spec, Map overrides) { + Map all = new HashMap<>(); + for (Operation op : spec.operations()) { + all.put(op.operationId(), req -> Response.status(200)); + } + all.putAll(overrides); + return all; + } + protected HttpRequest newRequest( OpenApiServer server, String path, String method, BodyPublisher body) { var headers = Map.of("correlation-id", UUID.randomUUID().toString()); diff --git a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java index 5a6436c..7fe8692 100644 --- a/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java +++ b/src/test/java/com/retailsvc/http/TypeMapperRegistrationTest.java @@ -32,7 +32,7 @@ void gsonFallbackIsAutoRegisteredWhenNoJsonMapperConfigured() throws Exception { server = newBuilder() .spec(spec) - .handlers(Map.of("get-data", echo, "post-data", echo)) + .handlers(stubAllHandlers(Map.of("get-data", echo, "post-data", echo))) .port(0) .build(); HttpClient client = @@ -73,7 +73,7 @@ public byte[] writeTo(Object v) { newBuilder() .spec(spec) .bodyMapper("application/json", marker) - .handlers(Map.of("get-data", echo, "post-data", echo)) + .handlers(stubAllHandlers(Map.of("get-data", echo, "post-data", echo))) .port(0) .build(); HttpClient.newHttpClient() @@ -109,7 +109,7 @@ public byte[] writeTo(Object v) { newBuilder() .spec(spec) .jsonMapper(marker) - .handlers(Map.of("get-data", echo, "post-data", echo)) + .handlers(stubAllHandlers(Map.of("get-data", echo, "post-data", echo))) .port(0) .build(); HttpClient.newHttpClient() diff --git a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java index 9666dce..acd6dbd 100644 --- a/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java +++ b/src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java @@ -2,11 +2,9 @@ import static java.net.HttpURLConnection.HTTP_OK; 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.when; -import com.retailsvc.http.MissingOperationHandlerException; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import com.retailsvc.http.Response; @@ -54,20 +52,4 @@ void invokesRegisteredHandler() throws Exception { assertThat(called.get()).isTrue(); } - - @Test - void throwsWhenHandlerMissing() { - DispatchHandler d = dispatcher(Map.of()); - HttpExchange ex = stubExchange(); - - assertThatThrownBy( - () -> - withRequest( - "ghost", - () -> { - d.handle(ex); - return null; - })) - .isInstanceOf(MissingOperationHandlerException.class); - } } diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 104517a..4b0833b 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -37,6 +37,15 @@ public ServerLauncher() throws IOException { handlers.put("query-params", new ParamHandler()); handlers.put("path-params", new ParamHandler()); handlers.put("path-params-multi", new ParamHandler()); + handlers.put("post-shape", req -> Response.status(200)); + handlers.put("post-filter", req -> Response.status(200)); + handlers.put("post-blocked", req -> Response.status(200)); + handlers.put("format-email", req -> Response.status(200)); + handlers.put("format-int32", req -> Response.status(200)); + handlers.put("format-byte", req -> Response.status(200)); + handlers.put("post-gate", req -> Response.status(200)); + handlers.put("form-echo", req -> Response.status(200)); + handlers.put("text-echo", req -> Response.status(200)); handlers.put("secureApiKey", req -> Response.status(200)); handlers.put("secureBearer", req -> Response.status(200)); handlers.put("secureBasic", req -> Response.status(200));