Skip to content

Commit 017ea20

Browse files
committed
feat(spec): Add per-operation security with root-override semantics
Operation now carries an Optional<List<SecurityRequirement>> security field. When a path operation declares "security" in the OpenAPI doc, the parsed list is present (including an empty list to opt-out of root security); absent means no operation-level override.
1 parent 2df1385 commit 017ea20

6 files changed

Lines changed: 121 additions & 14 deletions

File tree

src/main/java/com/retailsvc/http/spec/Operation.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.retailsvc.http.spec;
22

3+
import com.retailsvc.http.spec.security.SecurityRequirement;
34
import java.util.List;
45
import java.util.Map;
56
import java.util.Optional;
@@ -11,4 +12,5 @@ public record Operation(
1112
Optional<RequestBody> requestBody,
1213
List<Parameter> parameters,
1314
Map<String, Response> responses,
14-
Map<String, Object> extensions) {}
15+
Map<String, Object> extensions,
16+
Optional<List<SecurityRequirement>> security) {}

src/main/java/com/retailsvc/http/spec/Spec.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,13 @@ private static Operation parseOperation(
285285
.orElse(List.of());
286286
Map<String, Response> responses =
287287
parseResponses((Map<String, Object>) raw.getOrDefault("responses", Map.of()));
288-
return new Operation(opId, method, path, body, params, responses, extractExtensions(raw));
288+
Optional<List<SecurityRequirement>> opSecurity =
289+
raw.containsKey("security")
290+
? Optional.of(
291+
SecuritySchemeParser.parseRequirements((List<Object>) raw.get("security")))
292+
: Optional.empty();
293+
return new Operation(
294+
opId, method, path, body, params, responses, extractExtensions(raw), opSecurity);
289295
}
290296

291297
private static Parameter resolveParameterOrParse(

src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ void successPathBindsRequestContextDuringChain() throws Exception {
9191
Optional.empty(),
9292
List.of(),
9393
Map.of(),
94-
Map.of());
94+
Map.of(),
95+
Optional.empty());
9596
Spec spec = specWith(op);
9697
Filter f = newFilter(spec);
9798
HttpExchange ex = exchange("GET", "/users/42", new byte[0]);
@@ -128,7 +129,8 @@ void unknownPathThrowsNotFound() {
128129
Optional.empty(),
129130
List.of(),
130131
Map.of(),
131-
Map.of()));
132+
Map.of(),
133+
Optional.empty()));
132134
Filter f = newFilter(spec);
133135

134136
HttpExchange ex = exchange("GET", "/missing", new byte[0]);
@@ -147,7 +149,8 @@ void wrongMethodThrowsMethodNotAllowed() {
147149
Optional.empty(),
148150
List.of(),
149151
Map.of(),
150-
Map.of()));
152+
Map.of(),
153+
Optional.empty()));
151154
Filter f = newFilter(spec);
152155

153156
HttpExchange ex = exchange("POST", "/x", new byte[0]);
@@ -167,7 +170,8 @@ void invalidQueryParamThrowsValidation() {
167170
Optional.empty(),
168171
List.of(new Parameter("q", Parameter.Location.QUERY, true, stringSchema)),
169172
Map.of(),
170-
Map.of());
173+
Map.of(),
174+
Optional.empty());
171175
Spec spec = specWith(op);
172176
Filter f = newFilter(spec);
173177

@@ -190,7 +194,8 @@ void integerQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
190194
Optional.empty(),
191195
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
192196
Map.of(),
193-
Map.of());
197+
Map.of(),
198+
Optional.empty());
194199
Spec spec = specWith(op);
195200
Filter f = newFilter(spec);
196201

@@ -212,7 +217,8 @@ void integerQueryParamRejectsNonNumericString() {
212217
Optional.empty(),
213218
List.of(new Parameter("n", Parameter.Location.QUERY, true, intSchema)),
214219
Map.of(),
215-
Map.of());
220+
Map.of(),
221+
Optional.empty());
216222
Spec spec = specWith(op);
217223
Filter f = newFilter(spec);
218224

@@ -235,7 +241,8 @@ void numberQueryParamIsCoercedFromStringBeforeValidation() throws Exception {
235241
Optional.empty(),
236242
List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)),
237243
Map.of(),
238-
Map.of());
244+
Map.of(),
245+
Optional.empty());
239246
Spec spec = specWith(op);
240247
Filter f = newFilter(spec);
241248

@@ -257,7 +264,8 @@ void numberQueryParamRejectsNonNumericString() {
257264
Optional.empty(),
258265
List.of(new Parameter("n", Parameter.Location.QUERY, true, numSchema)),
259266
Map.of(),
260-
Map.of());
267+
Map.of(),
268+
Optional.empty());
261269
Spec spec = specWith(op);
262270
Filter f = newFilter(spec);
263271

@@ -279,7 +287,8 @@ void booleanQueryParamCoercesTrueAndFalse() throws Exception {
279287
Optional.empty(),
280288
List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)),
281289
Map.of(),
282-
Map.of());
290+
Map.of(),
291+
Optional.empty());
283292
Spec spec = specWith(op);
284293
Filter f = newFilter(spec);
285294

@@ -304,7 +313,8 @@ void booleanQueryParamRejectsNonBooleanString() {
304313
Optional.empty(),
305314
List.of(new Parameter("b", Parameter.Location.QUERY, true, boolSchema)),
306315
Map.of(),
307-
Map.of());
316+
Map.of(),
317+
Optional.empty());
308318
Spec spec = specWith(op);
309319
Filter f = newFilter(spec);
310320

src/test/java/com/retailsvc/http/internal/RouterTest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
class RouterTest {
1414
private Operation op(String id, HttpMethod m, String path) {
1515
return new Operation(
16-
id, m, PathTemplate.compile(path), Optional.empty(), List.of(), Map.of(), Map.of());
16+
id,
17+
m,
18+
PathTemplate.compile(path),
19+
Optional.empty(),
20+
List.of(),
21+
Map.of(),
22+
Map.of(),
23+
Optional.empty());
1724
}
1825

1926
@Test

src/test/java/com/retailsvc/http/spec/OperationTest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@ void operationCarriesAllFields() {
2222
new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()));
2323
Operation op =
2424
new Operation(
25-
"get-user", HttpMethod.GET, path, Optional.empty(), List.of(param), Map.of(), Map.of());
25+
"get-user",
26+
HttpMethod.GET,
27+
path,
28+
Optional.empty(),
29+
List.of(param),
30+
Map.of(),
31+
Map.of(),
32+
Optional.empty());
2633
assertThat(op.operationId()).isEqualTo("get-user");
2734
assertThat(op.method()).isEqualTo(HttpMethod.GET);
2835
assertThat(op.parameters()).hasSize(1);

src/test/java/com/retailsvc/http/spec/SpecTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,79 @@ void securitySchemesDefaultsEmpty() {
120120
assertThat(spec.securitySchemes()).isEmpty();
121121
assertThat(spec.security()).isEmpty();
122122
}
123+
124+
@Test
125+
void operationLevelSecurityOverridesRoot() {
126+
Map<String, Object> raw =
127+
Map.of(
128+
"openapi", "3.1.0",
129+
"info", Map.of("title", "T", "version", "1"),
130+
"servers", List.of(Map.of("url", "/v1")),
131+
"security", List.of(Map.of("bearerAuth", List.of())),
132+
"paths",
133+
Map.of(
134+
"/x",
135+
Map.of(
136+
"get",
137+
Map.of(
138+
"operationId", "getX",
139+
"security", List.of(Map.of("apiKey", List.of())),
140+
"responses", Map.of("200", Map.of("description", "ok"))))));
141+
142+
Spec spec = Spec.from(raw);
143+
Operation op = spec.operations().getFirst();
144+
145+
assertThat(op.security()).isPresent();
146+
assertThat(op.security().get()).hasSize(1);
147+
assertThat(op.security().get().get(0).schemes()).containsKey("apiKey");
148+
}
149+
150+
@Test
151+
void operationEmptySecurityIsPreserved() {
152+
Map<String, Object> raw =
153+
Map.of(
154+
"openapi", "3.1.0",
155+
"info", Map.of("title", "T", "version", "1"),
156+
"servers", List.of(Map.of("url", "/v1")),
157+
"security", List.of(Map.of("bearerAuth", List.of())),
158+
"paths",
159+
Map.of(
160+
"/x",
161+
Map.of(
162+
"get",
163+
Map.of(
164+
"operationId", "getX",
165+
"security", List.of(),
166+
"responses", Map.of("200", Map.of("description", "ok"))))));
167+
168+
Spec spec = Spec.from(raw);
169+
Operation op = spec.operations().getFirst();
170+
171+
assertThat(op.security()).isPresent();
172+
assertThat(op.security().get()).isEmpty();
173+
}
174+
175+
@Test
176+
void operationWithoutSecurityIsEmptyOptional() {
177+
Map<String, Object> raw =
178+
Map.of(
179+
"openapi", "3.1.0",
180+
"info", Map.of("title", "T", "version", "1"),
181+
"servers", List.of(Map.of("url", "/v1")),
182+
"paths",
183+
Map.of(
184+
"/x",
185+
Map.of(
186+
"get",
187+
Map.of(
188+
"operationId",
189+
"getX",
190+
"responses",
191+
Map.of("200", Map.of("description", "ok"))))));
192+
193+
Spec spec = Spec.from(raw);
194+
Operation op = spec.operations().getFirst();
195+
196+
assertThat(op.security()).isEmpty();
197+
}
123198
}

0 commit comments

Comments
 (0)