|
| 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