Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
799 changes: 799 additions & 0 deletions docs/superpowers/plans/2026-05-08-openapi-extensions.md

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions docs/superpowers/specs/2026-05-08-openapi-extensions-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# OpenAPI extensions (x-* keys)

**Status:** design approved 2026-05-08
**Source inventory:** `docs/superpowers/specs/2026-05-07-openapi-refactor-design.md` §9, new Wave 2 (originally item #29)
**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.

## Goal

Preserve OpenAPI specification extensions (`x-*` keys) on the four most-used carriers and expose them through a typed accessor on the parsed model:

- `Spec`
- `Info`
- `Operation`
- every concrete `Schema` record (16 of them)

Today the parser silently drops `x-*` keys. After this change, consumers retrieve them via, e.g.:

```java
Object perms = operation.extensions().get("x-permissions");
```

## Non-goals

- 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.
- 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.
- Validating extension values / typing them in any way. Pure passthrough — value type is `Object`, consumer casts as needed.
- Detecting or rejecting unknown non-`x-*` keys. Those remain silently ignored, as today.

## Decisions

- **Per-carrier accessor.** Each affected record gains an `extensions()` component / method returning `Map<String, Object>`. No separate side-channel API on `Spec`.
- **Immutable.** Returned map is `Map.copyOf(...)` of the extracted entries; empty when none.
- **Stable iteration order.** Underlying collection is `LinkedHashMap` before the `Map.copyOf`, so consumers iterating get insertion order from the raw map.
- **`x-*` prefix only.** Strict `startsWith("x-")` filter. No special handling for `x_`, `X-`, etc.
- **Value type is `Object`.** Mirrors how the parser receives values from the consumer-supplied JSON/YAML mapper.

## Record shape changes

- `Spec` — add `Map<String, Object> extensions` as the final record component.
- `Info` — add the same component.
- `Operation` — add the same component.
- `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.

Constructors at every existing call site need a new argument; `Map.of()` is supplied where the parser sees no `x-*` keys.

## Parser changes

A single small helper, package-private to `com.retailsvc.http.spec`:

```java
static Map<String, Object> extractExtensions(Map<String, Object> raw) {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : raw.entrySet()) {
if (e.getKey().startsWith("x-")) {
out.put(e.getKey(), e.getValue());
}
}
return Map.copyOf(out);
}
```

Call sites:

- `Spec.from(raw)` — pass `extractExtensions(raw)` to the new `Spec` constructor.
- `parseInfo(raw)` — pass `extractExtensions(raw)` to the new `Info` constructor.
- `parseOperation(...)` — pass `extractExtensions(raw)` to the new `Operation` constructor.
- `SchemaParser.parse(rawMap)` — extract once at the top of each `parseXxxSchema` branch and thread into every record constructor.

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.

## Behavior preserved

- Validation paths are untouched. `x-*` keys are not validated.
- Unknown non-`x-*` keys remain silently ignored, exactly as today.
- 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()`.

## Tests

Unit tests in `src/test/java/com/retailsvc/http/spec/`:

- `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()`.
- `InfoExtensionsTest` — `info` block with `x-contact-team: "platform"`; assert `spec.info().extensions().get(...)`.
- `OperationExtensionsTest` — operation with `x-permissions: ["pro.promotion.create"]`; assert `operation.extensions().get("x-permissions")` equals `List.of("pro.promotion.create")`.

Schema unit tests in `src/test/java/com/retailsvc/http/spec/schema/`:

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

Round-trip test:

- 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).
- New test parses the fixture via the production code path and asserts the value flows through to the typed `Operation`.

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.

## Acceptance criteria

- Every affected record (`Spec`, `Info`, `Operation`, all 16 `Schema` permits) exposes `extensions()` returning a non-null immutable `Map<String, Object>`.
- An `x-*` key on the corresponding raw map is present in the returned map; a non-`x-*` key is not.
- A carrier with no `x-*` keys returns `Map.of()` (equal-to-empty, not null).
- Existing unit and IT suites continue to pass — `mvn verify` green.
- Test fixtures `openapi.json` and `openapi.yaml` remain in sync.
- No new runtime dependencies.
4 changes: 3 additions & 1 deletion src/main/java/com/retailsvc/http/spec/Info.java
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
package com.retailsvc.http.spec;

public record Info(String title, String version) {}
import java.util.Map;

public record Info(String title, String version, Map<String, Object> extensions) {}
3 changes: 2 additions & 1 deletion src/main/java/com/retailsvc/http/spec/Operation.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ public record Operation(
PathTemplate path,
Optional<RequestBody> requestBody,
List<Parameter> parameters,
Map<String, Response> responses) {}
Map<String, Response> responses,
Map<String, Object> extensions) {}
20 changes: 16 additions & 4 deletions src/main/java/com/retailsvc/http/spec/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,23 @@ public record Spec(
Map<String, Parameter> componentParameters,
String basePath,
Map<String, Schema> schemaRefIndex,
Map<String, Parameter> parameterRefIndex) {
Map<String, Parameter> parameterRefIndex,
Map<String, Object> extensions) {

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

static Map<String, Object> extractExtensions(Map<String, Object> raw) {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : raw.entrySet()) {
if (e.getKey().startsWith("x-")) {
out.put(e.getKey(), e.getValue());
}
}
return Map.copyOf(out);
}

@SuppressWarnings("unchecked")
public static Spec from(Map<String, Object> raw) {
String openapi = (String) raw.get("openapi");
Expand All @@ -46,7 +57,8 @@ public static Spec from(Map<String, Object> raw) {
componentParameters,
computeBasePath(servers),
indexByRef(componentSchemas, SCHEMA_REF_PREFIX),
indexByRef(componentParameters, PARAMETER_REF_PREFIX));
indexByRef(componentParameters, PARAMETER_REF_PREFIX),
extractExtensions(raw));
}

private static String computeBasePath(List<Server> servers) {
Expand Down Expand Up @@ -88,7 +100,7 @@ private static String stripPrefix(String ref, String prefix) {
}

private static Info parseInfo(Map<String, Object> raw) {
return new Info((String) raw.get("title"), (String) raw.get("version"));
return new Info((String) raw.get("title"), (String) raw.get("version"), extractExtensions(raw));
}

private static List<Server> parseServers(List<Map<String, Object>> raw) {
Expand Down Expand Up @@ -167,7 +179,7 @@ private static Operation parseOperation(
.orElse(List.of());
Map<String, Response> responses =
parseResponses((Map<String, Object>) raw.getOrDefault("responses", Map.of()));
return new Operation(opId, method, path, body, params, responses);
return new Operation(opId, method, path, body, params, responses, extractExtensions(raw));
}

private static Parameter resolveParameterOrParse(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.List;
import java.util.Map;
import java.util.Set;

public record AllOfSchema(List<Schema> parts) implements Schema {
public record AllOfSchema(List<Schema> parts, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record AlwaysSchema() implements Schema {
public record AlwaysSchema(Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.List;
import java.util.Map;
import java.util.Set;

public record AnyOfSchema(List<Schema> options) implements Schema {
public record AnyOfSchema(List<Schema> options, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record ArraySchema(
Set<TypeName> types, Schema items, Integer minItems, Integer maxItems, boolean uniqueItems)
Set<TypeName> types,
Schema items,
Integer minItems,
Integer maxItems,
boolean uniqueItems,
Map<String, Object> extensions)
implements Schema {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record BooleanSchema(Set<TypeName> types) implements Schema {}
public record BooleanSchema(Set<TypeName> types, Map<String, Object> extensions)
implements Schema {}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record ConstSchema(Object value) implements Schema {
public record ConstSchema(Object value, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.List;
import java.util.Map;
import java.util.Set;

public record EnumSchema(List<Object> values) implements Schema {
public record EnumSchema(List<Object> values, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record IntegerSchema(
Expand All @@ -9,5 +10,6 @@ public record IntegerSchema(
Long exclusiveMinimum,
Long exclusiveMaximum,
Long multipleOf,
String format)
String format,
Map<String, Object> extensions)
implements Schema {}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record NeverSchema() implements Schema {
public record NeverSchema(Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/retailsvc/http/spec/schema/NotSchema.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record NotSchema(Schema schema) implements Schema {
public record NotSchema(Schema schema, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/retailsvc/http/spec/schema/NullSchema.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record NullSchema() implements Schema {
public record NullSchema(Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of(TypeName.NULL);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record NumberSchema(
Expand All @@ -9,5 +10,6 @@ public record NumberSchema(
Number exclusiveMinimum,
Number exclusiveMaximum,
Number multipleOf,
String format)
String format,
Map<String, Object> extensions)
implements Schema {}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ public record ObjectSchema(
List<String> required,
AdditionalProperties additionalProperties,
Integer minProperties,
Integer maxProperties)
Integer maxProperties,
Map<String, Object> extensions)
implements Schema {}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.List;
import java.util.Map;
import java.util.Set;

public record OneOfSchema(List<Schema> options) implements Schema {
public record OneOfSchema(List<Schema> options, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/retailsvc/http/spec/schema/RefSchema.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public record RefSchema(String pointer) implements Schema {
public record RefSchema(String pointer, Map<String, Object> extensions) implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/retailsvc/http/spec/schema/Schema.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.retailsvc.http.spec.schema;

import java.util.Map;
import java.util.Set;

public sealed interface Schema
Expand All @@ -20,4 +21,6 @@ public sealed interface Schema
AlwaysSchema,
NeverSchema {
Set<TypeName> types();

Map<String, Object> extensions();
}
Loading
Loading