Skip to content

Commit 75d3a1e

Browse files
committed
feat: ExtraRoute accepts RequestHandler via ExtraRouteAdapter
1 parent 2f55b12 commit 75d3a1e

8 files changed

Lines changed: 144 additions & 80 deletions

File tree

src/main/java/com/retailsvc/http/Handlers.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
66
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
77
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
8-
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
98
import static java.net.HttpURLConnection.HTTP_OK;
109
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
1110

1211
import com.retailsvc.http.internal.ClasspathResourceHandler;
1312
import com.retailsvc.http.internal.ProblemDetail;
14-
import com.sun.net.httpserver.HttpHandler;
1513
import java.util.List;
1614
import java.util.Objects;
1715
import java.util.function.Supplier;
@@ -52,14 +50,6 @@ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
5250
};
5351
}
5452

55-
public static HttpHandler notFoundHandler() {
56-
return exchange -> {
57-
try (exchange) {
58-
exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1);
59-
}
60-
};
61-
}
62-
6353
/** Returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} otherwise. */
6454
public static RequestHandler aliveHandler() {
6555
return req ->

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
import com.retailsvc.http.internal.DispatchHandler;
88
import com.retailsvc.http.internal.ExceptionFilter;
9+
import com.retailsvc.http.internal.ExtraRouteAdapter;
910
import com.retailsvc.http.internal.FormTypeMapper;
11+
import com.retailsvc.http.internal.NotFoundHandler;
1012
import com.retailsvc.http.internal.RequestPreparationFilter;
1113
import com.retailsvc.http.internal.ResponseRenderer;
1214
import com.retailsvc.http.internal.Router;
@@ -18,7 +20,6 @@
1820
import com.retailsvc.http.spec.security.SecurityScheme;
1921
import com.retailsvc.http.validate.DefaultValidator;
2022
import com.sun.net.httpserver.HttpContext;
21-
import com.sun.net.httpserver.HttpHandler;
2223
import com.sun.net.httpserver.HttpServer;
2324
import java.io.IOException;
2425
import java.net.InetSocketAddress;
@@ -56,7 +57,7 @@ record HandlerConfig(
5657
List<RequestInterceptor> interceptors,
5758
List<ResponseDecorator> decorators,
5859
ExceptionHandler exceptionHandler,
59-
Map<String, HttpHandler> extras,
60+
Map<String, RequestHandler> extras,
6061
Map<String, SchemeValidator> securityValidators,
6162
boolean externalAuth) {}
6263

@@ -103,13 +104,13 @@ record HandlerConfig(
103104
handlerConfig.decorators(),
104105
renderer));
105106

106-
for (Map.Entry<String, HttpHandler> e : handlerConfig.extras().entrySet()) {
107+
for (Map.Entry<String, RequestHandler> e : handlerConfig.extras().entrySet()) {
107108
HttpContext extraCtx = httpServer.createContext(e.getKey());
108109
extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer));
109-
extraCtx.setHandler(e.getValue());
110+
extraCtx.setHandler(new ExtraRouteAdapter(e.getValue(), renderer));
110111
}
111112

112-
httpServer.createContext("/", Handlers.notFoundHandler());
113+
httpServer.createContext("/", new NotFoundHandler());
113114
httpServer.start();
114115

115116
this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;
@@ -156,7 +157,7 @@ public static final class Builder {
156157
private ExceptionHandler exceptionHandler;
157158
private int port = DEFAULT_PORT;
158159
private int shutdownTimeoutSeconds = 0;
159-
private final LinkedHashMap<String, HttpHandler> extras = new LinkedHashMap<>();
160+
private final LinkedHashMap<String, RequestHandler> extras = new LinkedHashMap<>();
160161
private final Map<String, SchemeValidator> securityValidators = new LinkedHashMap<>();
161162
private boolean externalAuth = false;
162163

@@ -256,7 +257,7 @@ public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) {
256257
* anything that isn't an OpenAPI {@code operationId}. For OpenAPI-described operations use
257258
* {@link #handlers(Map)}.
258259
*/
259-
public Builder extraRoute(String path, HttpHandler handler) {
260+
public Builder extraRoute(String path, RequestHandler handler) {
260261
requireNonNull(path, "path must not be null");
261262
requireNonNull(handler, "handler must not be null");
262263
if (extras.containsKey(path)) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Request;
4+
import com.retailsvc.http.RequestHandler;
5+
import com.retailsvc.http.Response;
6+
import com.retailsvc.http.spec.HttpMethod;
7+
import com.sun.net.httpserver.HttpExchange;
8+
import com.sun.net.httpserver.HttpHandler;
9+
import java.io.IOException;
10+
import java.util.Map;
11+
12+
/**
13+
* Bridges an extra-route {@link RequestHandler} to the underlying JDK {@link HttpHandler}.
14+
*
15+
* <p>Builds a {@link Request} with {@code operationId=null}, empty path-params, empty principals,
16+
* raw body bytes, raw query, and the parsed HTTP method, then renders the returned {@link Response}
17+
* through the shared {@link ResponseRenderer}. OpenAPI validation, body parsing, and security are
18+
* intentionally bypassed — extras are by definition outside the spec.
19+
*/
20+
public final class ExtraRouteAdapter implements HttpHandler {
21+
22+
private final RequestHandler handler;
23+
private final ResponseRenderer renderer;
24+
25+
public ExtraRouteAdapter(RequestHandler handler, ResponseRenderer renderer) {
26+
this.handler = handler;
27+
this.renderer = renderer;
28+
}
29+
30+
@Override
31+
public void handle(HttpExchange exchange) throws IOException {
32+
byte[] body = exchange.getRequestBody().readAllBytes();
33+
HttpMethod method = HttpMethod.parse(exchange.getRequestMethod());
34+
var headers = exchange.getRequestHeaders();
35+
Request request =
36+
new Request(
37+
body,
38+
null,
39+
null,
40+
null,
41+
Map.of(),
42+
exchange.getRequestURI().getRawQuery(),
43+
headers::getFirst,
44+
Map.of(),
45+
method);
46+
Response response = handler.handle(request);
47+
renderer.render(exchange, response);
48+
}
49+
}

src/main/java/com/retailsvc/http/internal/MethodLimitedHandler.java

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
4+
5+
import com.sun.net.httpserver.HttpExchange;
6+
import com.sun.net.httpserver.HttpHandler;
7+
import java.io.IOException;
8+
9+
/** Returns 404 with no body. Used for the framework's catch-all {@code /} context. */
10+
public final class NotFoundHandler implements HttpHandler {
11+
12+
@Override
13+
public void handle(HttpExchange exchange) throws IOException {
14+
try (exchange) {
15+
exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1);
16+
}
17+
}
18+
}

src/test/java/com/retailsvc/http/ExtraHandlersIT.java

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,14 @@
1111
class ExtraHandlersIT extends ServerBaseTest {
1212

1313
@Test
14-
// MIGRATED-IN-TASK-6: re-enable Handlers.aliveHandler() once extraRoute accepts RequestHandler
1514
void aliveExtraReturns204AndBypassesValidation() throws Exception {
16-
com.sun.net.httpserver.HttpHandler alive =
17-
ex -> {
18-
try (ex) {
19-
ex.sendResponseHeaders(204, -1);
20-
}
21-
};
2215
try (var s =
23-
newBuilder().spec(spec).handlers(Map.of()).port(0).extraRoute("/alive", alive).build();
16+
newBuilder()
17+
.spec(spec)
18+
.handlers(Map.of())
19+
.port(0)
20+
.extraRoute("/alive", Handlers.aliveHandler())
21+
.build();
2422
var client = httpClient()) {
2523

2624
var req =
@@ -36,23 +34,13 @@ void aliveExtraReturns204AndBypassesValidation() throws Exception {
3634
}
3735

3836
@Test
39-
// MIGRATED-IN-TASK-6: re-enable Handlers.specHandler() once extraRoute accepts RequestHandler
4037
void specHandlerServesClasspathResource() throws Exception {
41-
byte[] yamlBytes = ExtraHandlersIT.class.getResourceAsStream("/openapi.yaml").readAllBytes();
42-
com.sun.net.httpserver.HttpHandler serveYaml =
43-
ex -> {
44-
try (ex) {
45-
ex.getResponseHeaders().add("Content-Type", "application/yaml");
46-
ex.sendResponseHeaders(200, yamlBytes.length);
47-
ex.getResponseBody().write(yamlBytes);
48-
}
49-
};
5038
try (var s =
5139
newBuilder()
5240
.spec(spec)
5341
.handlers(Map.of())
5442
.port(0)
55-
.extraRoute("/openapi.yaml", serveYaml)
43+
.extraRoute("/openapi.yaml", Handlers.specHandler("/openapi.yaml"))
5644
.build();
5745
var client = httpClient()) {
5846

@@ -71,8 +59,8 @@ void specHandlerServesClasspathResource() throws Exception {
7159

7260
@Test
7361
void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception {
74-
com.sun.net.httpserver.HttpHandler boom =
75-
ex -> {
62+
RequestHandler boom =
63+
req -> {
7664
throw new RuntimeException("boom");
7765
};
7866

src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
66

77
import com.retailsvc.http.spec.Spec;
8-
import com.sun.net.httpserver.HttpHandler;
98
import java.util.List;
109
import java.util.Map;
1110
import org.junit.jupiter.api.Test;
@@ -26,9 +25,7 @@ void buildsWithRequiredFieldsOnly() {
2625

2726
@Test
2827
void rejectsDuplicateExtraPathOnSecondAddHandler() {
29-
// MIGRATED-IN-TASK-6: replace stub with Handlers.aliveHandler() once extraRoute accepts
30-
// RequestHandler
31-
HttpHandler duplicate = exchange -> {};
28+
RequestHandler duplicate = req -> Response.empty();
3229
OpenApiServer.Builder b =
3330
OpenApiServer.builder().spec(spec).handlers(emptyMap()).extraRoute("/alive", duplicate);
3431

@@ -40,13 +37,11 @@ void rejectsDuplicateExtraPathOnSecondAddHandler() {
4037
@Test
4138
void rejectsExtraPathEqualToSpecBasePathAtBuildTime() {
4239
// testSpec() uses "/api" as the basePath (servers[0].url = http://localhost:8080/api).
43-
// MIGRATED-IN-TASK-6: replace stub with Handlers.aliveHandler() once extraRoute accepts
44-
// RequestHandler
4540
OpenApiServer.Builder b =
4641
OpenApiServer.builder()
4742
.spec(spec)
4843
.handlers(emptyMap())
49-
.extraRoute("/api", exchange -> {})
44+
.extraRoute("/api", Handlers.aliveHandler())
5045
.port(0);
5146

5247
assertThatThrownBy(b::build)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.when;
6+
7+
import com.retailsvc.http.GsonTypeMapper;
8+
import com.retailsvc.http.Request;
9+
import com.retailsvc.http.RequestHandler;
10+
import com.retailsvc.http.Response;
11+
import com.retailsvc.http.TypeMapper;
12+
import com.retailsvc.http.spec.HttpMethod;
13+
import com.sun.net.httpserver.Headers;
14+
import com.sun.net.httpserver.HttpExchange;
15+
import java.io.ByteArrayInputStream;
16+
import java.io.ByteArrayOutputStream;
17+
import java.net.URI;
18+
import java.util.Map;
19+
import java.util.concurrent.atomic.AtomicReference;
20+
import org.junit.jupiter.api.Test;
21+
22+
class ExtraRouteAdapterTest {
23+
24+
@Test
25+
void buildsRequestWithMethodQueryHeadersAndBodyBytesAndNullOperationId() throws Exception {
26+
AtomicReference<Request> captured = new AtomicReference<>();
27+
RequestHandler handler =
28+
req -> {
29+
captured.set(req);
30+
return Response.empty();
31+
};
32+
33+
Map<String, TypeMapper> mappers = Map.of("application/json", new GsonTypeMapper());
34+
ResponseRenderer renderer = new ResponseRenderer(mappers);
35+
ExtraRouteAdapter adapter = new ExtraRouteAdapter(handler, renderer);
36+
37+
HttpExchange ex = mock(HttpExchange.class);
38+
Headers reqHeaders = new Headers();
39+
reqHeaders.add("X-Trace", "abc");
40+
when(ex.getRequestMethod()).thenReturn("POST");
41+
when(ex.getRequestURI()).thenReturn(new URI("/alive?x=1"));
42+
when(ex.getRequestHeaders()).thenReturn(reqHeaders);
43+
when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream("hi".getBytes()));
44+
when(ex.getResponseHeaders()).thenReturn(new Headers());
45+
when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream());
46+
47+
adapter.handle(ex);
48+
49+
Request r = captured.get();
50+
assertThat(r.operationId()).isNull();
51+
assertThat(r.pathParams()).isEmpty();
52+
assertThat(r.principals()).isEmpty();
53+
assertThat(r.method()).isEqualTo(HttpMethod.POST);
54+
assertThat(r.rawQuery()).isEqualTo("x=1");
55+
assertThat(r.header("X-Trace")).contains("abc");
56+
assertThat(r.bytes()).containsExactly('h', 'i');
57+
}
58+
}

0 commit comments

Comments
 (0)