Skip to content

Commit 1a223af

Browse files
authored
feat: OpenAPI extensions (#47)
1 parent 547e5fe commit 1a223af

39 files changed

Lines changed: 1385 additions & 138 deletions

docs/superpowers/plans/2026-05-08-openapi-extensions.md

Lines changed: 799 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# OpenAPI extensions (x-* keys)
2+
3+
**Status:** design approved 2026-05-08
4+
**Source inventory:** `docs/superpowers/specs/2026-05-07-openapi-refactor-design.md` §9, new Wave 2 (originally item #29)
5+
**Driving use case:** consumers attach extensions like `x-permissions: ["pro.promotion.create"]` to operations and expect to retrieve them from the typed model in order to drive auth/permission logic in their own filters.
6+
7+
## Goal
8+
9+
Preserve OpenAPI specification extensions (`x-*` keys) on the four most-used carriers and expose them through a typed accessor on the parsed model:
10+
11+
- `Spec`
12+
- `Info`
13+
- `Operation`
14+
- every concrete `Schema` record (16 of them)
15+
16+
Today the parser silently drops `x-*` keys. After this change, consumers retrieve them via, e.g.:
17+
18+
```java
19+
Object perms = operation.extensions().get("x-permissions");
20+
```
21+
22+
## Non-goals
23+
24+
- Adding extensions to `Server`, `Parameter`, `RequestBody`, `MediaType`, `Response`. Pragmatic-tier scope per brainstorming; can be added later if/when consumer pain emerges. Adding record components later at this `0.0.1-local` stage is acceptable per the project's "break freely" policy.
25+
- Exposing the per-request `Operation` to user-supplied `HttpHandler` instances. The handler-access path is acknowledged as useful but deferred to its own spec/PR. The unit + parser tests in this spec prove the data lives on the typed model; demonstrating end-to-end handler retrieval is a follow-up.
26+
- Validating extension values / typing them in any way. Pure passthrough — value type is `Object`, consumer casts as needed.
27+
- Detecting or rejecting unknown non-`x-*` keys. Those remain silently ignored, as today.
28+
29+
## Decisions
30+
31+
- **Per-carrier accessor.** Each affected record gains an `extensions()` component / method returning `Map<String, Object>`. No separate side-channel API on `Spec`.
32+
- **Immutable.** Returned map is `Map.copyOf(...)` of the extracted entries; empty when none.
33+
- **Stable iteration order.** Underlying collection is `LinkedHashMap` before the `Map.copyOf`, so consumers iterating get insertion order from the raw map.
34+
- **`x-*` prefix only.** Strict `startsWith("x-")` filter. No special handling for `x_`, `X-`, etc.
35+
- **Value type is `Object`.** Mirrors how the parser receives values from the consumer-supplied JSON/YAML mapper.
36+
37+
## Record shape changes
38+
39+
- `Spec` — add `Map<String, Object> extensions` as the final record component.
40+
- `Info` — add the same component.
41+
- `Operation` — add the same component.
42+
- `Schema` (sealed interface) — add abstract method `Map<String, Object> extensions();` next to the existing `Set<TypeName> types();`. Every concrete record (`StringSchema`, `NumberSchema`, `IntegerSchema`, `BooleanSchema`, `NullSchema`, `ObjectSchema`, `ArraySchema`, `OneOfSchema`, `AnyOfSchema`, `AllOfSchema`, `NotSchema`, `ConstSchema`, `EnumSchema`, `RefSchema`, `AlwaysSchema`, `NeverSchema`) gains an `extensions` component.
43+
44+
Constructors at every existing call site need a new argument; `Map.of()` is supplied where the parser sees no `x-*` keys.
45+
46+
## Parser changes
47+
48+
A single small helper, package-private to `com.retailsvc.http.spec`:
49+
50+
```java
51+
static Map<String, Object> extractExtensions(Map<String, Object> raw) {
52+
Map<String, Object> out = new LinkedHashMap<>();
53+
for (var e : raw.entrySet()) {
54+
if (e.getKey().startsWith("x-")) {
55+
out.put(e.getKey(), e.getValue());
56+
}
57+
}
58+
return Map.copyOf(out);
59+
}
60+
```
61+
62+
Call sites:
63+
64+
- `Spec.from(raw)` — pass `extractExtensions(raw)` to the new `Spec` constructor.
65+
- `parseInfo(raw)` — pass `extractExtensions(raw)` to the new `Info` constructor.
66+
- `parseOperation(...)` — pass `extractExtensions(raw)` to the new `Operation` constructor.
67+
- `SchemaParser.parse(rawMap)` — extract once at the top of each `parseXxxSchema` branch and thread into every record constructor.
68+
69+
For schemas where the helper isn't trivially reachable (different package), duplicate the helper as package-private inside `com.retailsvc.http.spec.schema` rather than widening visibility — the implementation is three lines.
70+
71+
## Behavior preserved
72+
73+
- Validation paths are untouched. `x-*` keys are not validated.
74+
- Unknown non-`x-*` keys remain silently ignored, exactly as today.
75+
- The two existing test fixtures (`openapi.json`, `openapi.yaml`) without `x-*` keys must continue to parse identically and produce records whose `extensions()` returns `Map.of()`.
76+
77+
## Tests
78+
79+
Unit tests in `src/test/java/com/retailsvc/http/spec/`:
80+
81+
- `SpecExtensionsTest` — top-level spec with `x-vendor-build: "abc"`; assert `spec.extensions().get("x-vendor-build")` returns `"abc"`. Empty case: spec without any `x-*` returns `Map.of()`.
82+
- `InfoExtensionsTest``info` block with `x-contact-team: "platform"`; assert `spec.info().extensions().get(...)`.
83+
- `OperationExtensionsTest` — operation with `x-permissions: ["pro.promotion.create"]`; assert `operation.extensions().get("x-permissions")` equals `List.of("pro.promotion.create")`.
84+
85+
Schema unit tests in `src/test/java/com/retailsvc/http/spec/schema/`:
86+
87+
- `SchemaParserExtensionsTest` — covers `ObjectSchema` and `StringSchema` (representatives of the larger family; all 14 schemas share the same extraction path in `SchemaParser`). Add at least one test for a combinator (`OneOfSchema`) and one for a primitive that takes few other keywords (`BooleanSchema`) to lock in coverage of the "thin" record paths.
88+
89+
Round-trip test:
90+
91+
- Add `x-permissions: ["pro.promotion.create"]` to one operation (e.g., `create-promotion`-style) in `src/test/resources/openapi.json` and mirror in `src/test/resources/openapi.yaml` (project rule: fixtures must mirror).
92+
- New test parses the fixture via the production code path and asserts the value flows through to the typed `Operation`.
93+
94+
No integration test is added in this PR because handler access to `Operation` is out of scope; that's the round-trip test's job.
95+
96+
## Acceptance criteria
97+
98+
- Every affected record (`Spec`, `Info`, `Operation`, all 16 `Schema` permits) exposes `extensions()` returning a non-null immutable `Map<String, Object>`.
99+
- An `x-*` key on the corresponding raw map is present in the returned map; a non-`x-*` key is not.
100+
- A carrier with no `x-*` keys returns `Map.of()` (equal-to-empty, not null).
101+
- Existing unit and IT suites continue to pass — `mvn verify` green.
102+
- Test fixtures `openapi.json` and `openapi.yaml` remain in sync.
103+
- No new runtime dependencies.
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
package com.retailsvc.http.spec;
22

3-
public record Info(String title, String version) {}
3+
import java.util.Map;
4+
5+
public record Info(String title, String version, Map<String, Object> extensions) {}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ public record Operation(
1010
PathTemplate path,
1111
Optional<RequestBody> requestBody,
1212
List<Parameter> parameters,
13-
Map<String, Response> responses) {}
13+
Map<String, Response> responses,
14+
Map<String, Object> extensions) {}

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ public record Spec(
1919
Map<String, Parameter> componentParameters,
2020
String basePath,
2121
Map<String, Schema> schemaRefIndex,
22-
Map<String, Parameter> parameterRefIndex) {
22+
Map<String, Parameter> parameterRefIndex,
23+
Map<String, Object> extensions) {
2324

2425
private static final String SCHEMA_KEY = "schema";
2526
private static final String SCHEMA_REF_PREFIX = "#/components/schemas/";
2627
private static final String PARAMETER_REF_PREFIX = "#/components/parameters/";
2728

29+
static Map<String, Object> extractExtensions(Map<String, Object> raw) {
30+
Map<String, Object> out = new LinkedHashMap<>();
31+
for (var e : raw.entrySet()) {
32+
if (e.getKey().startsWith("x-")) {
33+
out.put(e.getKey(), e.getValue());
34+
}
35+
}
36+
return Map.copyOf(out);
37+
}
38+
2839
@SuppressWarnings("unchecked")
2940
public static Spec from(Map<String, Object> raw) {
3041
String openapi = (String) raw.get("openapi");
@@ -46,7 +57,8 @@ public static Spec from(Map<String, Object> raw) {
4657
componentParameters,
4758
computeBasePath(servers),
4859
indexByRef(componentSchemas, SCHEMA_REF_PREFIX),
49-
indexByRef(componentParameters, PARAMETER_REF_PREFIX));
60+
indexByRef(componentParameters, PARAMETER_REF_PREFIX),
61+
extractExtensions(raw));
5062
}
5163

5264
private static String computeBasePath(List<Server> servers) {
@@ -88,7 +100,7 @@ private static String stripPrefix(String ref, String prefix) {
88100
}
89101

90102
private static Info parseInfo(Map<String, Object> raw) {
91-
return new Info((String) raw.get("title"), (String) raw.get("version"));
103+
return new Info((String) raw.get("title"), (String) raw.get("version"), extractExtensions(raw));
92104
}
93105

94106
private static List<Server> parseServers(List<Map<String, Object>> raw) {
@@ -167,7 +179,7 @@ private static Operation parseOperation(
167179
.orElse(List.of());
168180
Map<String, Response> responses =
169181
parseResponses((Map<String, Object>) raw.getOrDefault("responses", Map.of()));
170-
return new Operation(opId, method, path, body, params, responses);
182+
return new Operation(opId, method, path, body, params, responses, extractExtensions(raw));
171183
}
172184

173185
private static Parameter resolveParameterOrParse(

src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package com.retailsvc.http.spec.schema;
22

33
import java.util.List;
4+
import java.util.Map;
45
import java.util.Set;
56

6-
public record AllOfSchema(List<Schema> parts) implements Schema {
7+
public record AllOfSchema(List<Schema> parts, Map<String, Object> extensions) implements Schema {
78
@Override
89
public Set<TypeName> types() {
910
return Set.of();

src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package com.retailsvc.http.spec.schema;
22

3+
import java.util.Map;
34
import java.util.Set;
45

5-
public record AlwaysSchema() implements Schema {
6+
public record AlwaysSchema(Map<String, Object> extensions) implements Schema {
67
@Override
78
public Set<TypeName> types() {
89
return Set.of();

src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package com.retailsvc.http.spec.schema;
22

33
import java.util.List;
4+
import java.util.Map;
45
import java.util.Set;
56

6-
public record AnyOfSchema(List<Schema> options) implements Schema {
7+
public record AnyOfSchema(List<Schema> options, Map<String, Object> extensions) implements Schema {
78
@Override
89
public Set<TypeName> types() {
910
return Set.of();
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package com.retailsvc.http.spec.schema;
22

3+
import java.util.Map;
34
import java.util.Set;
45

56
public record ArraySchema(
6-
Set<TypeName> types, Schema items, Integer minItems, Integer maxItems, boolean uniqueItems)
7+
Set<TypeName> types,
8+
Schema items,
9+
Integer minItems,
10+
Integer maxItems,
11+
boolean uniqueItems,
12+
Map<String, Object> extensions)
713
implements Schema {}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.retailsvc.http.spec.schema;
22

3+
import java.util.Map;
34
import java.util.Set;
45

5-
public record BooleanSchema(Set<TypeName> types) implements Schema {}
6+
public record BooleanSchema(Set<TypeName> types, Map<String, Object> extensions)
7+
implements Schema {}

0 commit comments

Comments
 (0)