Skip to content

Commit d6c0047

Browse files
committed
docs: Add design for OpenAPI extensions (x-* keys)
1 parent 05210c2 commit d6c0047

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

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.

0 commit comments

Comments
 (0)