Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down

This file was deleted.

21 changes: 21 additions & 0 deletions src/main/java/com/retailsvc/http/OpenApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -339,6 +340,7 @@ public OpenApiServer build() throws IOException {
if (!externalAuth) {
validateSecurityWiring(spec, securityValidators);
}
validateHandlerWiring(spec, handlers);
Map<String, TypeMapper> resolved = resolveBodyMappers(bodyMappers);
ExceptionHandler effectiveExceptionHandler =
exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler();
Expand All @@ -356,6 +358,25 @@ public OpenApiServer build() throws IOException {
spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds);
}

private static void validateHandlerWiring(Spec spec, Map<String, RequestHandler> handlers) {
Set<String> specOps = new TreeSet<>();
for (Operation op : spec.operations()) {
specOps.add(op.operationId());
}
Set<String> missing = new TreeSet<>(specOps);
missing.removeAll(handlers.keySet());
if (!missing.isEmpty()) {
throw new IllegalStateException(
"no handler registered for spec operationId(s): " + missing);
}
Set<String> 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<String, SchemeValidator> validators) {
Set<String> referenced = new LinkedHashSet<>();
for (Operation op : spec.operations()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
104 changes: 45 additions & 59 deletions src/test/java/com/retailsvc/http/AfterResponseHookIT.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")) {
Expand All @@ -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())
Expand All @@ -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);
Expand Down Expand Up @@ -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()
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {

Expand All @@ -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());
Expand All @@ -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<Response> 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<Void> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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");
Expand Down
18 changes: 14 additions & 4 deletions src/test/java/com/retailsvc/http/ExtraHandlersIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -63,7 +63,12 @@ void textHtmlResponseIsSerializedByDefaultMapper() throws Exception {
req -> Response.of(200, "<h1>hi</h1>").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 =
Expand All @@ -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 =
Expand Down
Loading
Loading