Skip to content

Commit 5b89fbe

Browse files
thcedclaude
andcommitted
feat(internal): RequestPreparationFilter combines body capture, routing, validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a6ed432 commit 5b89fbe

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static com.retailsvc.http.Request.BODY;
4+
import static com.retailsvc.http.Request.OPERATION_ID;
5+
import static com.retailsvc.http.Request.PARSED_BODY;
6+
import static com.retailsvc.http.Request.PATH_PARAMETERS;
7+
8+
import com.retailsvc.http.JsonMapper;
9+
import com.retailsvc.http.MethodNotAllowedException;
10+
import com.retailsvc.http.NotFoundException;
11+
import com.retailsvc.http.ValidationException;
12+
import com.retailsvc.http.spec.HttpMethod;
13+
import com.retailsvc.http.spec.MediaType;
14+
import com.retailsvc.http.spec.Operation;
15+
import com.retailsvc.http.spec.Parameter;
16+
import com.retailsvc.http.spec.RequestBody;
17+
import com.retailsvc.http.spec.Spec;
18+
import com.retailsvc.http.validate.ValidationError;
19+
import com.retailsvc.http.validate.Validator;
20+
import com.sun.net.httpserver.Filter;
21+
import com.sun.net.httpserver.HttpExchange;
22+
import java.io.IOException;
23+
import java.util.HashMap;
24+
import java.util.Locale;
25+
import java.util.Map;
26+
import java.util.Optional;
27+
28+
public final class RequestPreparationFilter extends Filter {
29+
30+
private final Spec spec;
31+
private final Router router;
32+
private final Validator validator;
33+
private final JsonMapper jsonMapper;
34+
35+
public RequestPreparationFilter(
36+
Spec spec, Router router, Validator validator, JsonMapper jsonMapper) {
37+
this.spec = spec;
38+
this.router = router;
39+
this.validator = validator;
40+
this.jsonMapper = jsonMapper;
41+
}
42+
43+
@Override
44+
public String description() {
45+
return "Request preparation";
46+
}
47+
48+
@Override
49+
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
50+
byte[] body = exchange.getRequestBody().readAllBytes();
51+
exchange.setAttribute(BODY, body);
52+
53+
HttpMethod method = HttpMethod.parse(exchange.getRequestMethod());
54+
String path = stripBasePath(exchange.getRequestURI().getPath());
55+
56+
var matchOpt = router.match(method, path);
57+
if (matchOpt.isEmpty()) {
58+
var allowed = router.allowedMethods(path);
59+
if (allowed.isEmpty()) {
60+
throw new NotFoundException(method + " " + path);
61+
}
62+
throw new MethodNotAllowedException(allowed);
63+
}
64+
Router.Match match = matchOpt.get();
65+
66+
Operation op = match.operation();
67+
exchange.setAttribute(OPERATION_ID, op.operationId());
68+
exchange.setAttribute(PATH_PARAMETERS, match.pathParameters());
69+
70+
validateParameters(exchange, op, match.pathParameters());
71+
validateBody(exchange, op, body);
72+
73+
chain.doFilter(exchange);
74+
}
75+
76+
private String stripBasePath(String path) {
77+
String base = spec.basePath();
78+
if (base == null || base.isEmpty() || base.equals("/")) {
79+
return path;
80+
}
81+
return path.startsWith(base) ? path.substring(base.length()) : path;
82+
}
83+
84+
private void validateParameters(
85+
HttpExchange exchange, Operation op, Map<String, String> pathParams) {
86+
Map<String, String> query = parseQuery(exchange.getRequestURI().getQuery());
87+
for (Parameter p : op.parameters()) {
88+
String pointer = "/" + p.in().name().toLowerCase(Locale.ROOT) + "/" + p.name();
89+
String value =
90+
switch (p.in()) {
91+
case PATH -> pathParams.get(p.name());
92+
case QUERY -> query.get(p.name());
93+
case HEADER -> exchange.getRequestHeaders().getFirst(p.name());
94+
case COOKIE -> null; // handled by future spec
95+
};
96+
if (value == null) {
97+
if (p.required()) {
98+
throw new ValidationException(
99+
new ValidationError(
100+
pointer,
101+
"required",
102+
"required " + p.in().name().toLowerCase(Locale.ROOT) + " parameter is missing",
103+
null));
104+
}
105+
continue;
106+
}
107+
validator.validate(value, p.schema(), pointer);
108+
}
109+
}
110+
111+
private void validateBody(HttpExchange exchange, Operation op, byte[] body) {
112+
Optional<RequestBody> rb = op.requestBody();
113+
if (rb.isEmpty()) {
114+
return;
115+
}
116+
if (body.length == 0) {
117+
if (rb.get().required()) {
118+
throw new ValidationException(
119+
new ValidationError("/body", "required", "request body is required", null));
120+
}
121+
return;
122+
}
123+
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
124+
if (contentType == null) {
125+
contentType = "application/json";
126+
}
127+
contentType = contentType.split(";", 2)[0].trim();
128+
MediaType mt = rb.get().content().get(contentType);
129+
if (mt == null) {
130+
throw new ValidationException(
131+
new ValidationError(
132+
"/body", "content-type", "unsupported content type: " + contentType, null));
133+
}
134+
Object parsed = jsonMapper.mapFrom(body);
135+
exchange.setAttribute(PARSED_BODY, parsed);
136+
validator.validate(parsed, mt.schema(), "");
137+
}
138+
139+
private static Map<String, String> parseQuery(String query) {
140+
if (query == null || query.isBlank()) {
141+
return Map.of();
142+
}
143+
Map<String, String> out = new HashMap<>();
144+
for (String pair : query.split("&")) {
145+
int eq = pair.indexOf('=');
146+
if (eq <= 0) {
147+
continue;
148+
}
149+
out.putIfAbsent(pair.substring(0, eq), pair.substring(eq + 1));
150+
}
151+
return out;
152+
}
153+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.retailsvc.http.JsonMapper;
7+
import com.retailsvc.http.MethodNotAllowedException;
8+
import com.retailsvc.http.NotFoundException;
9+
import com.retailsvc.http.Request;
10+
import com.retailsvc.http.ValidationException;
11+
import com.retailsvc.http.spec.HttpMethod;
12+
import com.retailsvc.http.spec.Info;
13+
import com.retailsvc.http.spec.Operation;
14+
import com.retailsvc.http.spec.Parameter;
15+
import com.retailsvc.http.spec.PathTemplate;
16+
import com.retailsvc.http.spec.Server;
17+
import com.retailsvc.http.spec.Spec;
18+
import com.retailsvc.http.spec.schema.StringSchema;
19+
import com.retailsvc.http.spec.schema.TypeName;
20+
import com.retailsvc.http.validate.DefaultValidator;
21+
import com.sun.net.httpserver.Filter;
22+
import com.sun.net.httpserver.Headers;
23+
import com.sun.net.httpserver.HttpExchange;
24+
import java.io.ByteArrayInputStream;
25+
import java.net.URI;
26+
import java.util.HashMap;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.Optional;
30+
import java.util.Set;
31+
import org.junit.jupiter.api.Test;
32+
import org.mockito.Mockito;
33+
34+
class RequestPreparationFilterTest {
35+
private final HashMap<String, Object> attrs = new HashMap<>();
36+
37+
private HttpExchange exchange(String method, String path, byte[] body) {
38+
HttpExchange ex = Mockito.mock(HttpExchange.class);
39+
Mockito.when(ex.getRequestMethod()).thenReturn(method);
40+
Mockito.when(ex.getRequestURI()).thenReturn(URI.create(path));
41+
Mockito.when(ex.getRequestHeaders()).thenReturn(new Headers());
42+
Mockito.when(ex.getRequestBody()).thenReturn(new ByteArrayInputStream(body));
43+
Mockito.doAnswer(inv -> attrs.put(inv.getArgument(0), inv.getArgument(1)))
44+
.when(ex)
45+
.setAttribute(Mockito.anyString(), Mockito.any());
46+
Mockito.when(ex.getAttribute(Mockito.anyString()))
47+
.thenAnswer(inv -> attrs.get((String) inv.getArgument(0)));
48+
return ex;
49+
}
50+
51+
private Spec specWith(Operation... ops) {
52+
return new Spec(
53+
"3.1.0", new Info("t", "1"), List.of(new Server("/")), List.of(ops), Map.of(), Map.of());
54+
}
55+
56+
@Test
57+
void successPathSetsAttributes() throws Exception {
58+
var op =
59+
new Operation(
60+
"get-user",
61+
HttpMethod.GET,
62+
PathTemplate.compile("/users/{id}"),
63+
Optional.empty(),
64+
List.of(),
65+
Map.of());
66+
Spec spec = specWith(op);
67+
JsonMapper m = body -> new String(body);
68+
Filter f =
69+
new RequestPreparationFilter(
70+
spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), m);
71+
72+
HttpExchange ex = exchange("GET", "/users/42", new byte[0]);
73+
Filter.Chain chain = Mockito.mock(Filter.Chain.class);
74+
75+
f.doFilter(ex, chain);
76+
77+
assertThat(Request.operationId(ex)).isEqualTo("get-user");
78+
assertThat(Request.pathParams(ex)).containsEntry("id", "42");
79+
Mockito.verify(chain).doFilter(ex);
80+
}
81+
82+
@Test
83+
void unknownPathThrowsNotFound() {
84+
Spec spec =
85+
specWith(
86+
new Operation(
87+
"a",
88+
HttpMethod.GET,
89+
PathTemplate.compile("/x"),
90+
Optional.empty(),
91+
List.of(),
92+
Map.of()));
93+
JsonMapper m = body -> new String(body);
94+
Filter f =
95+
new RequestPreparationFilter(
96+
spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), m);
97+
98+
HttpExchange ex = exchange("GET", "/missing", new byte[0]);
99+
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
100+
.isInstanceOf(NotFoundException.class);
101+
}
102+
103+
@Test
104+
void wrongMethodThrowsMethodNotAllowed() {
105+
Spec spec =
106+
specWith(
107+
new Operation(
108+
"a",
109+
HttpMethod.GET,
110+
PathTemplate.compile("/x"),
111+
Optional.empty(),
112+
List.of(),
113+
Map.of()));
114+
JsonMapper m = body -> new String(body);
115+
Filter f =
116+
new RequestPreparationFilter(
117+
spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), m);
118+
119+
HttpExchange ex = exchange("POST", "/x", new byte[0]);
120+
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
121+
.isInstanceOf(MethodNotAllowedException.class);
122+
}
123+
124+
@Test
125+
void invalidQueryParamThrowsValidation() {
126+
var stringSchema = new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null);
127+
var op =
128+
new Operation(
129+
"a",
130+
HttpMethod.GET,
131+
PathTemplate.compile("/x"),
132+
Optional.empty(),
133+
List.of(new Parameter("q", Parameter.Location.QUERY, true, stringSchema)),
134+
Map.of());
135+
Spec spec = specWith(op);
136+
JsonMapper m = body -> new String(body);
137+
Filter f =
138+
new RequestPreparationFilter(
139+
spec, new Router(spec.operations()), new DefaultValidator(spec::resolveSchema), m);
140+
141+
HttpExchange ex = exchange("GET", "/x?q=ab", new byte[0]);
142+
assertThatThrownBy(() -> f.doFilter(ex, Mockito.mock(Filter.Chain.class)))
143+
.isInstanceOf(ValidationException.class)
144+
.extracting(t -> ((ValidationException) t).error().pointer())
145+
.isEqualTo("/query/q");
146+
}
147+
}

0 commit comments

Comments
 (0)