From d6c0047b06a8f0a552a8bc6d0f8c06922dacd7bb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:07:43 +0200 Subject: [PATCH 1/8] docs: Add design for OpenAPI extensions (x-* keys) --- .../2026-05-08-openapi-extensions-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-openapi-extensions-design.md diff --git a/docs/superpowers/specs/2026-05-08-openapi-extensions-design.md b/docs/superpowers/specs/2026-05-08-openapi-extensions-design.md new file mode 100644 index 0000000..5487a62 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-openapi-extensions-design.md @@ -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`. 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 extensions` as the final record component. +- `Info` — add the same component. +- `Operation` — add the same component. +- `Schema` (sealed interface) — add abstract method `Map extensions();` next to the existing `Set 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 extractExtensions(Map raw) { + Map 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`. +- 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. From d1e6114755bbf426bdb454077235531cc0a60faa Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:11:38 +0200 Subject: [PATCH 2/8] docs: Add implementation plan for OpenAPI extensions --- .../plans/2026-05-08-openapi-extensions.md | 799 ++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-openapi-extensions.md diff --git a/docs/superpowers/plans/2026-05-08-openapi-extensions.md b/docs/superpowers/plans/2026-05-08-openapi-extensions.md new file mode 100644 index 0000000..7ba93a2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-openapi-extensions.md @@ -0,0 +1,799 @@ +# OpenAPI Extensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve `x-*` keys on parsed `Spec`, `Info`, `Operation`, and every concrete `Schema` record, exposed as `Map extensions()`. + +**Architecture:** Each affected record gains an `extensions` component (final field). A small `extractExtensions` helper filters a raw `Map` to its `x-*` entries and is invoked at every parse site. `Schema` (sealed interface) gains an abstract `extensions()` method that all 16 concrete records implement. + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Maven. + +**Spec:** `docs/superpowers/specs/2026-05-08-openapi-extensions-design.md` + +**Conventions to honor:** +- Google Java Formatter (pre-commit autoruns; never hand-format). +- Always use curly braces — no brace-less one-liners. +- Test method names: camelCase only. +- `openapi.json` and `openapi.yaml` test fixtures must mirror each other. +- Conventional Commits (commitlint enforces). +- No `Co-Authored-By` trailer. +- LSP diagnostics after each edit; fix type errors immediately. + +**Scale note:** Adding a record component requires updating every constructor call site. There are roughly 100 schema-construction sites across main + test code. Use Maven (`mvn compile`) and the Java compiler errors as a TODO list — every red squiggle becomes a `, Map.of()` addition. + +--- + +## File Structure + +**Modify (records):** +- `src/main/java/com/retailsvc/http/spec/Spec.java` +- `src/main/java/com/retailsvc/http/spec/Info.java` +- `src/main/java/com/retailsvc/http/spec/Operation.java` +- `src/main/java/com/retailsvc/http/spec/schema/Schema.java` (interface) +- All 16 concrete schemas in `src/main/java/com/retailsvc/http/spec/schema/`: + `StringSchema`, `NumberSchema`, `IntegerSchema`, `BooleanSchema`, `NullSchema`, `ObjectSchema`, `ArraySchema`, `OneOfSchema`, `AnyOfSchema`, `AllOfSchema`, `NotSchema`, `ConstSchema`, `EnumSchema`, `RefSchema`, `AlwaysSchema`, `NeverSchema`. +- `src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java` + +**Modify (tests):** +- Existing test files where records are constructed (all must gain a trailing `Map.of()`): + - `src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java` + - `src/test/java/com/retailsvc/http/spec/OperationTest.java` + - `src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java` + - `src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java` + - `src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java` + - `src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java` + - `src/test/java/com/retailsvc/http/spec/schema/SchemaParserTest.java` + - `src/test/java/com/retailsvc/http/validate/*.java` (any that construct schemas inline — e.g. `StringIntegerNumberTest`, `ArrayValidationTest`, etc.) + - Any other test that constructs a record from this list. + +**Create (new tests):** +- `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` — covers `Spec`, `Info`, `Operation`, and schema extensions in one focused test class. + +**Modify (fixtures):** +- `src/test/resources/openapi.json` (add `x-permissions` to one operation). +- `src/test/resources/openapi.yaml` (mirror). + +--- + +## Task 1: Add `extensions` to `Spec` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/spec/Spec.java` +- Create: `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` + +- [ ] **Step 1: Verify baseline is green** + +Run: `mvn test` +Expected: BUILD SUCCESS. + +- [ ] **Step 2: Write the failing test** + +Create `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java`: + +```java +package com.retailsvc.http.spec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExtensionsTest { + + @Test + void specExtensionsExposeTopLevelXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "x-vendor-build", + "abc"); + Spec spec = Spec.from(raw); + assertThat(spec.extensions()).containsEntry("x-vendor-build", "abc"); + } + + @Test + void specExtensionsEmptyWhenNoXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of()); + Spec spec = Spec.from(raw); + assertThat(spec.extensions()).isEmpty(); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails (compilation error: no `extensions()` method)** + +Run: `mvn test -Dtest=ExtensionsTest` +Expected: COMPILATION FAILURE — `extensions()` is undefined on `Spec`. + +- [ ] **Step 4: Add `extensions` component to `Spec` and wire the parser** + +In `src/main/java/com/retailsvc/http/spec/Spec.java`: + +a) Add `Map extensions` as the last record component: + +```java +public record Spec( + String openapi, + Info info, + List servers, + List operations, + Map componentSchemas, + Map componentParameters, + String basePath, + Map schemaRefIndex, + Map parameterRefIndex, + Map extensions) { +``` + +b) Add a package-private helper inside the class (just below the `PARAMETER_REF_PREFIX` constant): + +```java +static Map extractExtensions(Map raw) { + Map out = new LinkedHashMap<>(); + for (var e : raw.entrySet()) { + if (e.getKey().startsWith("x-")) { + out.put(e.getKey(), e.getValue()); + } + } + return Map.copyOf(out); +} +``` + +c) Update `Spec.from(raw)` to pass `extractExtensions(raw)` as the last argument: + +```java +return new Spec( + openapi, + info, + servers, + operations, + componentSchemas, + componentParameters, + computeBasePath(servers), + indexByRef(componentSchemas, SCHEMA_REF_PREFIX), + indexByRef(componentParameters, PARAMETER_REF_PREFIX), + extractExtensions(raw)); +``` + +- [ ] **Step 5: Find every other construction of `Spec` and add `Map.of()`** + +Run: `mvn compile && mvn test-compile 2>&1 | tail -30` +Expected: any compilation errors point to other `new Spec(...)` call sites missing the new argument. + +Fix each by appending `, Map.of()` as the last argument. (Likely there are very few — `Spec` is mostly constructed via `Spec.from`.) + +- [ ] **Step 6: Run tests** + +Run: `mvn test -Dtest=ExtensionsTest` +Expected: PASS. + +Run: `mvn test` +Expected: BUILD SUCCESS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/Spec.java src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +git commit -m "feat: Preserve OpenAPI extensions on Spec" +``` + +--- + +## Task 2: Add `extensions` to `Info` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/spec/Info.java` +- Modify: `src/main/java/com/retailsvc/http/spec/Spec.java` +- Modify: `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` + +- [ ] **Step 1: Append failing test** + +In `ExtensionsTest.java`, add: + +```java +@Test +void infoExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1", "x-contact-team", "platform"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of()); + Spec spec = Spec.from(raw); + assertThat(spec.info().extensions()).containsEntry("x-contact-team", "platform"); +} +``` + +- [ ] **Step 2: Run test — expect compilation failure** + +Run: `mvn test -Dtest=ExtensionsTest#infoExtensionsExposeXKeys` +Expected: COMPILATION FAILURE. + +- [ ] **Step 3: Update `Info` and `parseInfo`** + +In `src/main/java/com/retailsvc/http/spec/Info.java`: + +```java +package com.retailsvc.http.spec; + +import java.util.Map; + +public record Info(String title, String version, Map extensions) {} +``` + +In `src/main/java/com/retailsvc/http/spec/Spec.java`, update `parseInfo`: + +```java +private static Info parseInfo(Map raw) { + return new Info((String) raw.get("title"), (String) raw.get("version"), extractExtensions(raw)); +} +``` + +- [ ] **Step 4: Fix all other `new Info(...)` call sites** + +Run: `mvn compile && mvn test-compile 2>&1 | grep -E "error|Info"` +Expected: list of `new Info(...)` call sites in tests; append `, Map.of()` to each. + +Known site to update: `src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java` line 36 — change `new Info("test", "1.0.0")` to `new Info("test", "1.0.0", Map.of())`. + +- [ ] **Step 5: Run tests** + +Run: `mvn test -Dtest=ExtensionsTest` +Expected: PASS. + +Run: `mvn test` +Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/Info.java src/main/java/com/retailsvc/http/spec/Spec.java src/test/java/com/retailsvc/http/spec/ExtensionsTest.java src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java +git commit -m "feat: Preserve OpenAPI extensions on Info" +``` + +--- + +## Task 3: Add `extensions` to `Operation` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/spec/Operation.java` +- Modify: `src/main/java/com/retailsvc/http/spec/Spec.java` +- Modify: `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` +- Modify: `src/test/java/com/retailsvc/http/spec/OperationTest.java` + +- [ ] **Step 1: Append failing test** + +In `ExtensionsTest.java`, add: + +```java +@Test +void operationExtensionsExposeXPermissions() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of( + "/promotions", + Map.of( + "post", + Map.of( + "operationId", + "createPromotion", + "x-permissions", + List.of("pro.promotion.create"), + "responses", + Map.of())))); + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + assertThat(op.extensions()).containsEntry("x-permissions", List.of("pro.promotion.create")); +} +``` + +- [ ] **Step 2: Run test — expect compilation failure** + +Run: `mvn test -Dtest=ExtensionsTest#operationExtensionsExposeXPermissions` +Expected: COMPILATION FAILURE. + +- [ ] **Step 3: Update `Operation` record and `parseOperation`** + +In `src/main/java/com/retailsvc/http/spec/Operation.java`: + +```java +package com.retailsvc.http.spec; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record Operation( + String operationId, + HttpMethod method, + PathTemplate path, + Optional requestBody, + List parameters, + Map responses, + Map extensions) {} +``` + +In `src/main/java/com/retailsvc/http/spec/Spec.java`, change the last line of `parseOperation`: + +```java +return new Operation(opId, method, path, body, params, responses, extractExtensions(raw)); +``` + +- [ ] **Step 4: Fix all other `new Operation(...)` call sites** + +Run: `mvn compile && mvn test-compile 2>&1 | grep -E "error|Operation"` +Expected: list of `new Operation(...)` call sites. + +Known site: `src/test/java/com/retailsvc/http/spec/OperationTest.java` line 21 — append `, Map.of()`. + +- [ ] **Step 5: Run tests** + +Run: `mvn test -Dtest=ExtensionsTest` +Expected: PASS. + +Run: `mvn test` +Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/Operation.java src/main/java/com/retailsvc/http/spec/Spec.java src/test/java/com/retailsvc/http/spec/ExtensionsTest.java src/test/java/com/retailsvc/http/spec/OperationTest.java +git commit -m "feat: Preserve OpenAPI extensions on Operation" +``` + +--- + +## Task 4: Add `extensions` to every `Schema` record + +This is the largest mechanical task. The sealed interface gains an abstract method; each of the 16 concrete records gains a component. The compiler will surface every test/main call site that constructs a schema — fix each by appending `Map.of()` as the new last argument. + +**Files (main):** +- Modify: `src/main/java/com/retailsvc/http/spec/schema/Schema.java` +- Modify: every concrete schema record in `src/main/java/com/retailsvc/http/spec/schema/` +- Modify: `src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java` + +**Files (tests):** +- Modify: `src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java` +- Modify: `src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java` +- Modify: `src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java` +- Modify: `src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java` +- Modify: `src/test/java/com/retailsvc/http/spec/schema/SchemaParserTest.java` +- Modify: any test under `src/test/java/com/retailsvc/http/validate/` that constructs a schema inline +- Modify: `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` (add schema extension tests) + +- [ ] **Step 1: Append failing schema extension tests** + +In `ExtensionsTest.java`, add tests covering at least one primitive, one container, and one combinator (the rest share the parser code path): + +```java +@Test +void objectSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of( + "Promotion", + Map.of("type", "object", "properties", Map.of(), "x-ui-hint", "card")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Promotion").extensions()) + .containsEntry("x-ui-hint", "card"); +} + +@Test +void stringSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of("Code", Map.of("type", "string", "x-format-hint", "slug")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Code").extensions()) + .containsEntry("x-format-hint", "slug"); +} + +@Test +void oneOfSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of( + "Either", + Map.of( + "oneOf", + List.of(Map.of("type", "string"), Map.of("type", "integer")), + "x-discriminator-hint", + "kind")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Either").extensions()) + .containsEntry("x-discriminator-hint", "kind"); +} +``` + +- [ ] **Step 2: Run tests — expect compilation failure** + +Run: `mvn test -Dtest=ExtensionsTest` +Expected: COMPILATION FAILURE — schemas have no `extensions()` method. + +- [ ] **Step 3: Add abstract method to `Schema` interface** + +In `src/main/java/com/retailsvc/http/spec/schema/Schema.java`: + +```java +package com.retailsvc.http.spec.schema; + +import java.util.Map; +import java.util.Set; + +public sealed interface Schema + permits StringSchema, + NumberSchema, + IntegerSchema, + BooleanSchema, + ObjectSchema, + ArraySchema, + NullSchema, + RefSchema, + OneOfSchema, + AnyOfSchema, + AllOfSchema, + NotSchema, + ConstSchema, + EnumSchema, + AlwaysSchema, + NeverSchema { + Set types(); + + Map extensions(); +} +``` + +- [ ] **Step 4: Add `extensions` component to every concrete schema record** + +For each file in `src/main/java/com/retailsvc/http/spec/schema/`, add `Map extensions` as the final component. The full list: + +```java +// StringSchema.java +public record StringSchema( + Set types, + String pattern, + Integer minLength, + Integer maxLength, + String format, + List enumValues, + Map extensions) + implements Schema {} + +// NumberSchema.java +public record NumberSchema( + Set types, + Number minimum, + Number maximum, + Number exclusiveMinimum, + Number exclusiveMaximum, + Number multipleOf, + String format, + Map extensions) + implements Schema {} + +// IntegerSchema.java +public record IntegerSchema( + Set types, + Long minimum, + Long maximum, + Long exclusiveMinimum, + Long exclusiveMaximum, + Long multipleOf, + String format, + Map extensions) + implements Schema {} + +// BooleanSchema.java +public record BooleanSchema(Set types, Map extensions) + implements Schema {} + +// NullSchema.java — preserve any existing methods (e.g., types()) +public record NullSchema(Map extensions) implements Schema { + // keep existing types() override if present +} + +// ObjectSchema.java +public record ObjectSchema( + Set types, + Map properties, + List required, + AdditionalProperties additionalProperties, + Integer minProperties, + Integer maxProperties, + Map extensions) + implements Schema {} + +// ArraySchema.java +public record ArraySchema( + Set types, + Schema items, + Integer minItems, + Integer maxItems, + boolean uniqueItems, + Map extensions) + implements Schema {} + +// RefSchema.java — preserve existing types() override +public record RefSchema(String pointer, Map extensions) implements Schema { + // keep existing types() override +} + +// OneOfSchema.java +public record OneOfSchema(List options, Map extensions) + implements Schema { + // keep existing types() override +} + +// AnyOfSchema.java +public record AnyOfSchema(List options, Map extensions) + implements Schema { + // keep existing types() override +} + +// AllOfSchema.java +public record AllOfSchema(List parts, Map extensions) + implements Schema { + // keep existing types() override +} + +// NotSchema.java +public record NotSchema(Schema schema, Map extensions) implements Schema { + // keep existing types() override +} + +// ConstSchema.java +public record ConstSchema(Object value, Map extensions) implements Schema { + // keep existing types() override +} + +// EnumSchema.java +public record EnumSchema(List values, Map extensions) + implements Schema { + // keep existing types() override +} + +// AlwaysSchema.java +public record AlwaysSchema(Map extensions) implements Schema { + // keep existing types() override +} + +// NeverSchema.java +public record NeverSchema(Map extensions) implements Schema { + // keep existing types() override +} +``` + +**Important:** Open each file before editing and preserve any existing `types()` override and any other methods inside the record body. Add only the new component to the signature; do not remove or rewrite the body. + +- [ ] **Step 5: Update `SchemaParser` to thread extensions through every record constructor** + +In `src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java`: + +a) Add the helper (same shape as `Spec.extractExtensions`, package-private): + +```java +static Map extractExtensions(Map raw) { + Map out = new LinkedHashMap<>(); + for (var e : raw.entrySet()) { + if (e.getKey().startsWith("x-")) { + out.put(e.getKey(), e.getValue()); + } + } + return Map.copyOf(out); +} +``` + +b) For `parse(Object raw)` boolean branch (`AlwaysSchema` / `NeverSchema`), pass `Map.of()` since booleans have no extensions: + +```java +public static Schema parse(Object raw) { + if (raw instanceof Boolean b) { + return b ? new AlwaysSchema(Map.of()) : new NeverSchema(Map.of()); + } + ... +} +``` + +c) For `parseMap(raw)`, every concrete construction must pass `extractExtensions(raw)` (or `Map.of()` for synthetic schemas where there is no source raw map — e.g. `permissiveObject()`, `new NullSchema(...)` synthesized for missing array items, `new ConstSchema(...)`). + +The synthesized cases (no source raw) should pass `Map.of()`: + +- `permissiveObject()` — no source raw. +- The `items == null` branch in `parseArray`, building `new NullSchema(...)` — no source raw. +- `parseAdditionalProperties`'s boolean cases — no source raw. + +The "has source raw" cases pass `extractExtensions(raw)`: + +- `new RefSchema((String) raw.get("$ref"), extractExtensions(raw))` +- `new ConstSchema(raw.get("const"), extractExtensions(raw))` +- `new EnumSchema(List.copyOf((List) raw.get("enum")), extractExtensions(raw))` +- `new StringSchema(..., extractExtensions(raw))` in `parseString` +- `new IntegerSchema(..., extractExtensions(raw))` in `parseInteger` +- `new NumberSchema(..., extractExtensions(raw))` in `parseNumber` +- `new BooleanSchema(types, extractExtensions(raw))` +- `new NullSchema(extractExtensions(raw))` +- `new ObjectSchema(..., extractExtensions(raw))` in `parseObject` +- `new ArraySchema(..., extractExtensions(raw))` in `parseArray` +- The combinator branches (`new AnyOfSchema(parseList(raw, "anyOf"), extractExtensions(raw))`, similarly `OneOfSchema` and `AllOfSchema`). +- `new NotSchema(parse(raw.get("not")), extractExtensions(raw))`. + +Note: `parseList(raw, "allOf")` returns a `List` that is added directly to the assertions list (no wrapping `AllOfSchema`). When `parseMap` ends up wrapping multiple assertions in an `AllOfSchema` via the `default` arm of the final `switch`, pass `Map.of()` for that synthesized wrapper — its extensions are already on the inner schemas: + +```java +default -> new AllOfSchema(List.copyOf(assertions), Map.of()); +``` + +- [ ] **Step 6: Build to surface every other construction site** + +Run: `mvn compile && mvn test-compile 2>&1 | grep -E "error" | head -50` + +Each compilation error pointing at a `new XxxSchema(...)` call in a test file (or anywhere else) needs a `, Map.of()` appended as the new last argument. + +Common sites in tests: +- `src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java` +- `src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java` +- `src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java` +- `src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java` +- `src/test/java/com/retailsvc/http/spec/schema/SchemaParserTest.java` +- `src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java` +- `src/test/java/com/retailsvc/http/validate/ArrayValidationTest.java` +- `src/test/java/com/retailsvc/http/validate/ObjectValidationTest.java` +- `src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java` + +Iterate until `mvn test-compile` reports BUILD SUCCESS. Some tests will require touching dozens of constructor lines. Use the IDE's quick-fix or a careful manual sweep. + +- [ ] **Step 7: Run the full unit test suite** + +Run: `mvn test` +Expected: BUILD SUCCESS, all tests pass (including the three new `ExtensionsTest` schema cases). + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/com/retailsvc/http/spec/schema src/test/java/com/retailsvc/http/spec/ExtensionsTest.java src/test/java/com/retailsvc/http/spec/schema src/test/java/com/retailsvc/http/validate +git commit -m "feat: Preserve OpenAPI extensions on every Schema record" +``` + +--- + +## Task 5: End-to-end fixture verification + +Adds `x-permissions` to a fixture operation and asserts that the value flows through `Spec.from(raw)` from the actual test fixture (not a synthetic map). + +**Files:** +- Modify: `src/test/resources/openapi.json` +- Modify: `src/test/resources/openapi.yaml` +- Modify: `src/test/java/com/retailsvc/http/spec/ExtensionsTest.java` + +- [ ] **Step 1: Add `x-permissions` to one operation in `openapi.json`** + +Pick an existing operation in `src/test/resources/openapi.json` (e.g., `post-data` or any small POST). Add an `"x-permissions": ["pro.promotion.create"]` key alongside `operationId`, indentation matching the surrounding keys. + +- [ ] **Step 2: Mirror in `openapi.yaml`** + +Add `x-permissions:` with the same value on the same operation. Match YAML indentation precisely. + +- [ ] **Step 3: Append fixture round-trip test** + +In `ExtensionsTest.java`, identify the path of the production code that loads `src/test/resources/openapi.json` in existing tests (search for `openapi.json` in `src/test/java/com/retailsvc/http/`). Mirror that fixture-load pattern and add: + +```java +@Test +void fixtureOperationExtensionsAreReadable() { + Spec spec = loadFixtureSpec(); // helper that mirrors existing fixture-loading pattern + Operation op = + spec.operations().stream() + .filter(o -> "".equals(o.operationId())) + .findFirst() + .orElseThrow(); + assertThat(op.extensions()).containsEntry("x-permissions", List.of("pro.promotion.create")); +} +``` + +Replace `` with the actual operationId chosen in Step 1. Inline the existing fixture-load logic into a helper or reuse whatever helper an existing test in `src/test/java/com/retailsvc/http/` provides — search for `Spec.from` invocations against the test resource for a template. + +- [ ] **Step 4: Run the test** + +Run: `mvn test -Dtest=ExtensionsTest#fixtureOperationExtensionsAreReadable` +Expected: PASS. + +Run: `mvn verify` +Expected: BUILD SUCCESS — full unit + IT suite green, fixtures still parse correctly. + +- [ ] **Step 5: Commit** + +```bash +git add src/test/resources/openapi.json src/test/resources/openapi.yaml src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +git commit -m "test: Verify x-permissions flows through fixture parse" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Confirm full build is clean** + +Run: `mvn verify` +Expected: BUILD SUCCESS, all unit + IT tests pass. + +- [ ] **Step 2: Sanity-check accessor exposure** + +Open the following files and confirm each declares a `Map extensions` component: + +- `src/main/java/com/retailsvc/http/spec/Spec.java` +- `src/main/java/com/retailsvc/http/spec/Info.java` +- `src/main/java/com/retailsvc/http/spec/Operation.java` +- All 16 files under `src/main/java/com/retailsvc/http/spec/schema/` matching `*Schema.java`. + +Open `src/main/java/com/retailsvc/http/spec/schema/Schema.java` and confirm it declares `Map extensions();` as an abstract method. + +- [ ] **Step 3: Push the branch** + +Per repo memory: gh CLI cannot create PRs in this repo — push and let the user open the PR manually. + +```bash +git push -u origin HEAD +``` + +Notify the user the branch is pushed and ready for them to open the PR. From 728274f2fb66fa17f607bef8c364dbf39c31b7c1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:13:38 +0200 Subject: [PATCH 3/8] feat: Preserve OpenAPI extensions on Spec --- .../java/com/retailsvc/http/spec/Spec.java | 16 ++++++- .../RequestPreparationFilterTest.java | 1 + .../retailsvc/http/spec/ExtensionsTest.java | 44 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/spec/ExtensionsTest.java diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index a6a3bde..f859780 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -19,12 +19,23 @@ public record Spec( Map componentParameters, String basePath, Map schemaRefIndex, - Map parameterRefIndex) { + Map parameterRefIndex, + Map 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 extractExtensions(Map raw) { + Map 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 raw) { String openapi = (String) raw.get("openapi"); @@ -46,7 +57,8 @@ public static Spec from(Map 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 servers) { diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index a4b888b..59de288 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -52,6 +52,7 @@ private Spec specWith(Operation... ops) { Map.of(), "", Map.of(), + Map.of(), Map.of()); } diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java new file mode 100644 index 0000000..518d3ba --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -0,0 +1,44 @@ +package com.retailsvc.http.spec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExtensionsTest { + + @Test + void specExtensionsExposeTopLevelXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "x-vendor-build", + "abc"); + Spec spec = Spec.from(raw); + assertThat(spec.extensions()).containsEntry("x-vendor-build", "abc"); + } + + @Test + void specExtensionsEmptyWhenNoXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of()); + Spec spec = Spec.from(raw); + assertThat(spec.extensions()).isEmpty(); + } +} From 99601c80d21f791fc7588c5c909a8c222009c482 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:16:54 +0200 Subject: [PATCH 4/8] feat: Preserve OpenAPI extensions on Info --- src/main/java/com/retailsvc/http/spec/Info.java | 4 +++- src/main/java/com/retailsvc/http/spec/Spec.java | 2 +- .../internal/RequestPreparationFilterTest.java | 2 +- .../com/retailsvc/http/spec/ExtensionsTest.java | 16 ++++++++++++++++ .../com/retailsvc/http/spec/SpecRecordsTest.java | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/Info.java b/src/main/java/com/retailsvc/http/spec/Info.java index bf6bb50..ed97d90 100644 --- a/src/main/java/com/retailsvc/http/spec/Info.java +++ b/src/main/java/com/retailsvc/http/spec/Info.java @@ -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 extensions) {} diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index f859780..1e3f1a6 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -100,7 +100,7 @@ private static String stripPrefix(String ref, String prefix) { } private static Info parseInfo(Map 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 parseServers(List> raw) { diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 59de288..7a2c3db 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -45,7 +45,7 @@ private HttpExchange exchange(String method, String path, byte[] body) { private Spec specWith(Operation... ops) { return new Spec( "3.1.0", - new Info("t", "1"), + new Info("t", "1", Map.of()), List.of(new Server("/")), List.of(ops), Map.of(), diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java index 518d3ba..6b620e9 100644 --- a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -41,4 +41,20 @@ void specExtensionsEmptyWhenNoXKeys() { Spec spec = Spec.from(raw); assertThat(spec.extensions()).isEmpty(); } + + @Test + void infoExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1", "x-contact-team", "platform"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of()); + Spec spec = Spec.from(raw); + assertThat(spec.info().extensions()).containsEntry("x-contact-team", "platform"); + } } diff --git a/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java b/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java index 5f13e3f..272a401 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java @@ -33,7 +33,7 @@ void serverHasUrl() { @Test void infoHasTitleAndVersion() { - Info i = new Info("test", "1.0.0"); + Info i = new Info("test", "1.0.0", Map.of()); assertThat(i.title()).isEqualTo("test"); assertThat(i.version()).isEqualTo("1.0.0"); } From 7e2749ca1a20b13d7e6a3838c0f5bb3b5fd2718f Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:45:31 +0200 Subject: [PATCH 5/8] feat: Preserve OpenAPI extensions on Operation --- .../com/retailsvc/http/spec/Operation.java | 3 ++- .../java/com/retailsvc/http/spec/Spec.java | 2 +- .../RequestPreparationFilterTest.java | 4 +++ .../retailsvc/http/internal/RouterTest.java | 3 ++- .../retailsvc/http/spec/ExtensionsTest.java | 27 +++++++++++++++++++ .../retailsvc/http/spec/OperationTest.java | 3 ++- 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/Operation.java b/src/main/java/com/retailsvc/http/spec/Operation.java index 3abe990..ea4203b 100644 --- a/src/main/java/com/retailsvc/http/spec/Operation.java +++ b/src/main/java/com/retailsvc/http/spec/Operation.java @@ -10,4 +10,5 @@ public record Operation( PathTemplate path, Optional requestBody, List parameters, - Map responses) {} + Map responses, + Map extensions) {} diff --git a/src/main/java/com/retailsvc/http/spec/Spec.java b/src/main/java/com/retailsvc/http/spec/Spec.java index 1e3f1a6..276cce1 100644 --- a/src/main/java/com/retailsvc/http/spec/Spec.java +++ b/src/main/java/com/retailsvc/http/spec/Spec.java @@ -179,7 +179,7 @@ private static Operation parseOperation( .orElse(List.of()); Map responses = parseResponses((Map) 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( diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 7a2c3db..07dedfa 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -71,6 +71,7 @@ void successPathBindsRequestContextDuringChain() throws Exception { PathTemplate.compile("/users/{id}"), Optional.empty(), List.of(), + Map.of(), Map.of()); Spec spec = specWith(op); Filter f = newFilter(spec); @@ -106,6 +107,7 @@ void unknownPathThrowsNotFound() { PathTemplate.compile("/x"), Optional.empty(), List.of(), + Map.of(), Map.of())); Filter f = newFilter(spec); @@ -124,6 +126,7 @@ void wrongMethodThrowsMethodNotAllowed() { PathTemplate.compile("/x"), Optional.empty(), List.of(), + Map.of(), Map.of())); Filter f = newFilter(spec); @@ -142,6 +145,7 @@ void invalidQueryParamThrowsValidation() { PathTemplate.compile("/x"), Optional.empty(), List.of(new Parameter("q", Parameter.Location.QUERY, true, stringSchema)), + Map.of(), Map.of()); Spec spec = specWith(op); Filter f = newFilter(spec); diff --git a/src/test/java/com/retailsvc/http/internal/RouterTest.java b/src/test/java/com/retailsvc/http/internal/RouterTest.java index ac2d813..4516f81 100644 --- a/src/test/java/com/retailsvc/http/internal/RouterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RouterTest.java @@ -12,7 +12,8 @@ class RouterTest { private Operation op(String id, HttpMethod m, String path) { - return new Operation(id, m, PathTemplate.compile(path), Optional.empty(), List.of(), Map.of()); + return new Operation( + id, m, PathTemplate.compile(path), Optional.empty(), List.of(), Map.of(), Map.of()); } @Test diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java index 6b620e9..9f4ec4a 100644 --- a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -57,4 +57,31 @@ void infoExtensionsExposeXKeys() { Spec spec = Spec.from(raw); assertThat(spec.info().extensions()).containsEntry("x-contact-team", "platform"); } + + @Test + void operationExtensionsExposeXPermissions() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of( + "/promotions", + Map.of( + "post", + Map.of( + "operationId", + "createPromotion", + "x-permissions", + List.of("pro.promotion.create"), + "responses", + Map.of())))); + Spec spec = Spec.from(raw); + Operation op = spec.operations().getFirst(); + assertThat(op.extensions()).containsEntry("x-permissions", List.of("pro.promotion.create")); + } } diff --git a/src/test/java/com/retailsvc/http/spec/OperationTest.java b/src/test/java/com/retailsvc/http/spec/OperationTest.java index f2479b7..6fa3f76 100644 --- a/src/test/java/com/retailsvc/http/spec/OperationTest.java +++ b/src/test/java/com/retailsvc/http/spec/OperationTest.java @@ -18,7 +18,8 @@ void operationCarriesAllFields() { new Parameter( "id", Parameter.Location.PATH, true, new BooleanSchema(Set.of(TypeName.BOOLEAN))); Operation op = - new Operation("get-user", HttpMethod.GET, path, Optional.empty(), List.of(param), Map.of()); + new Operation( + "get-user", HttpMethod.GET, path, Optional.empty(), List.of(param), Map.of(), Map.of()); assertThat(op.operationId()).isEqualTo("get-user"); assertThat(op.method()).isEqualTo(HttpMethod.GET); assertThat(op.parameters()).hasSize(1); From dba432ffa5ae34ff182d2517c7b3a429127b55e4 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 14:56:25 +0200 Subject: [PATCH 6/8] feat: Preserve OpenAPI extensions on every Schema record Add Map extensions as the final component to all 16 concrete Schema records and as an abstract method on the sealed Schema interface. SchemaParser.extractExtensions() threads x-* keys from the raw map through every concrete constructor; synthesized schemas with no source map receive Map.of(). DefaultValidator's record-deconstruct patterns gain the extra var _ binding. All existing test call sites updated with Map.of() as the new last argument. Three new tests in ExtensionsTest verify ObjectSchema, StringSchema, and OneOfSchema extension exposure. --- .../http/spec/schema/AllOfSchema.java | 3 +- .../http/spec/schema/AlwaysSchema.java | 3 +- .../http/spec/schema/AnyOfSchema.java | 3 +- .../http/spec/schema/ArraySchema.java | 8 +- .../http/spec/schema/BooleanSchema.java | 4 +- .../http/spec/schema/ConstSchema.java | 3 +- .../http/spec/schema/EnumSchema.java | 3 +- .../http/spec/schema/IntegerSchema.java | 4 +- .../http/spec/schema/NeverSchema.java | 3 +- .../retailsvc/http/spec/schema/NotSchema.java | 3 +- .../http/spec/schema/NullSchema.java | 3 +- .../http/spec/schema/NumberSchema.java | 4 +- .../http/spec/schema/ObjectSchema.java | 3 +- .../http/spec/schema/OneOfSchema.java | 3 +- .../retailsvc/http/spec/schema/RefSchema.java | 3 +- .../retailsvc/http/spec/schema/Schema.java | 3 + .../http/spec/schema/SchemaParser.java | 53 +++++++---- .../http/spec/schema/StringSchema.java | 4 +- .../http/validate/DefaultValidator.java | 14 +-- .../RequestPreparationFilterTest.java | 3 +- .../retailsvc/http/spec/ExtensionsTest.java | 69 +++++++++++++++ .../retailsvc/http/spec/OperationTest.java | 5 +- .../retailsvc/http/spec/SpecRecordsTest.java | 2 +- .../spec/schema/AdditionalPropertiesTest.java | 3 +- .../spec/schema/CombinatorScaffoldTest.java | 19 ++-- .../spec/schema/ContainerSchemasTest.java | 10 ++- .../spec/schema/PrimitiveSchemasTest.java | 14 +-- .../http/validate/ArrayValidationTest.java | 15 ++-- .../DefaultValidatorDispatchTest.java | 56 ++++++------ .../http/validate/ObjectValidationTest.java | 10 ++- .../validate/StringIntegerNumberTest.java | 87 +++++++++++++------ 31 files changed, 294 insertions(+), 126 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java index 947eea7..761e75a 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AllOfSchema.java @@ -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 parts) implements Schema { +public record AllOfSchema(List parts, Map extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java index 49787e9..1077b7d 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java index dcebcb9..b2fe66a 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/AnyOfSchema.java @@ -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 options) implements Schema { +public record AnyOfSchema(List options, Map extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java b/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java index bff54c1..fcd1805 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ArraySchema.java @@ -1,7 +1,13 @@ package com.retailsvc.http.spec.schema; +import java.util.Map; import java.util.Set; public record ArraySchema( - Set types, Schema items, Integer minItems, Integer maxItems, boolean uniqueItems) + Set types, + Schema items, + Integer minItems, + Integer maxItems, + boolean uniqueItems, + Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java b/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java index 94a1c19..7a4e310 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/BooleanSchema.java @@ -1,5 +1,7 @@ package com.retailsvc.http.spec.schema; +import java.util.Map; import java.util.Set; -public record BooleanSchema(Set types) implements Schema {} +public record BooleanSchema(Set types, Map extensions) + implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java b/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java index aeaf0b9..1076a0e 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ConstSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java b/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java index 73304a4..fe866af 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/EnumSchema.java @@ -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 values) implements Schema { +public record EnumSchema(List values, Map extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java b/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java index a4f28d5..d088f4c 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/IntegerSchema.java @@ -1,5 +1,6 @@ package com.retailsvc.http.spec.schema; +import java.util.Map; import java.util.Set; public record IntegerSchema( @@ -9,5 +10,6 @@ public record IntegerSchema( Long exclusiveMinimum, Long exclusiveMaximum, Long multipleOf, - String format) + String format, + Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java index d68098d..6600e24 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java index a508ed5..8c99e8a 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NotSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java index 1ed9dcf..c2585ac 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NullSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(TypeName.NULL); diff --git a/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java b/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java index 6b38048..af1008b 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/NumberSchema.java @@ -1,5 +1,6 @@ package com.retailsvc.http.spec.schema; +import java.util.Map; import java.util.Set; public record NumberSchema( @@ -9,5 +10,6 @@ public record NumberSchema( Number exclusiveMinimum, Number exclusiveMaximum, Number multipleOf, - String format) + String format, + Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java b/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java index dd50aba..e2481e2 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/ObjectSchema.java @@ -10,5 +10,6 @@ public record ObjectSchema( List required, AdditionalProperties additionalProperties, Integer minProperties, - Integer maxProperties) + Integer maxProperties, + Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java b/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java index 6275cf3..07a28ac 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/OneOfSchema.java @@ -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 options) implements Schema { +public record OneOfSchema(List options, Map extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java b/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java index ed79e66..c2b8b56 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/RefSchema.java @@ -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 extensions) implements Schema { @Override public Set types() { return Set.of(); diff --git a/src/main/java/com/retailsvc/http/spec/schema/Schema.java b/src/main/java/com/retailsvc/http/spec/schema/Schema.java index 2ac089b..46f1c3d 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/Schema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/Schema.java @@ -1,5 +1,6 @@ package com.retailsvc.http.spec.schema; +import java.util.Map; import java.util.Set; public sealed interface Schema @@ -20,4 +21,6 @@ public sealed interface Schema AlwaysSchema, NeverSchema { Set types(); + + Map extensions(); } diff --git a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java index bce1fee..bb9df3a 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java +++ b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java @@ -12,9 +12,19 @@ private SchemaParser() {} private static final String FORMAT_KEY = "format"; + static Map extractExtensions(Map raw) { + Map out = new LinkedHashMap<>(); + for (var e : raw.entrySet()) { + if (e.getKey().startsWith("x-")) { + out.put(e.getKey(), e.getValue()); + } + } + return Map.copyOf(out); + } + public static Schema parse(Object raw) { if (raw instanceof Boolean b) { - return b ? new AlwaysSchema() : new NeverSchema(); + return b ? new AlwaysSchema(Map.of()) : new NeverSchema(Map.of()); } if (raw instanceof Map map) { @SuppressWarnings("unchecked") @@ -27,7 +37,7 @@ public static Schema parse(Object raw) { @SuppressWarnings("unchecked") private static Schema parseMap(Map raw) { if (raw.containsKey("$ref")) { - return new RefSchema((String) raw.get("$ref")); + return new RefSchema((String) raw.get("$ref"), extractExtensions(raw)); } List assertions = new ArrayList<>(); @@ -41,29 +51,29 @@ private static Schema parseMap(Map raw) { assertions.addAll(parseList(raw, "allOf")); } if (raw.containsKey("anyOf")) { - assertions.add(new AnyOfSchema(parseList(raw, "anyOf"))); + assertions.add(new AnyOfSchema(parseList(raw, "anyOf"), extractExtensions(raw))); } if (raw.containsKey("oneOf")) { - assertions.add(new OneOfSchema(parseList(raw, "oneOf"))); + assertions.add(new OneOfSchema(parseList(raw, "oneOf"), extractExtensions(raw))); } if (raw.containsKey("not")) { - assertions.add(new NotSchema(parse(raw.get("not")))); + assertions.add(new NotSchema(parse(raw.get("not")), extractExtensions(raw))); } return switch (assertions.size()) { case 0 -> permissiveObject(); case 1 -> assertions.getFirst(); - default -> new AllOfSchema(List.copyOf(assertions)); + default -> new AllOfSchema(List.copyOf(assertions), Map.of()); }; } @SuppressWarnings("unchecked") private static Schema parseBaseIfPresent(Map raw) { if (raw.containsKey("const")) { - return new ConstSchema(raw.get("const")); + return new ConstSchema(raw.get("const"), extractExtensions(raw)); } if (raw.containsKey("enum") && !raw.containsKey("type")) { - return new EnumSchema(List.copyOf((List) raw.get("enum"))); + return new EnumSchema(List.copyOf((List) raw.get("enum")), extractExtensions(raw)); } Set types = parseTypes(raw); @@ -83,8 +93,8 @@ private static Schema parseBaseIfPresent(Map raw) { case STRING -> parseString(raw, types); case INTEGER -> parseInteger(raw, types); case NUMBER -> parseNumber(raw, types); - case BOOLEAN -> new BooleanSchema(types); - case NULL -> new NullSchema(); + case BOOLEAN -> new BooleanSchema(types, extractExtensions(raw)); + case NULL -> new NullSchema(extractExtensions(raw)); case OBJECT -> parseObject(raw, types); case ARRAY -> parseArray(raw, types); }; @@ -107,7 +117,7 @@ private static boolean hasArrayShapeKeywords(Map raw) { private static Schema permissiveObject() { return new ObjectSchema( - Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null); + Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null, Map.of()); } private static Set parseTypes(Map raw) { @@ -134,7 +144,8 @@ private static StringSchema parseString(Map raw, Set t toIntOrNull(raw.get("minLength")), toIntOrNull(raw.get("maxLength")), (String) raw.get(FORMAT_KEY), - (List) raw.get("enum")); + (List) raw.get("enum"), + extractExtensions(raw)); } private static IntegerSchema parseInteger(Map raw, Set types) { @@ -145,7 +156,8 @@ private static IntegerSchema parseInteger(Map raw, Set toLongOrNull(raw.get("exclusiveMinimum")), toLongOrNull(raw.get("exclusiveMaximum")), toLongOrNull(raw.get("multipleOf")), - (String) raw.get(FORMAT_KEY)); + (String) raw.get(FORMAT_KEY), + extractExtensions(raw)); } private static NumberSchema parseNumber(Map raw, Set types) { @@ -156,7 +168,8 @@ private static NumberSchema parseNumber(Map raw, Set t (Number) raw.get("exclusiveMinimum"), (Number) raw.get("exclusiveMaximum"), (Number) raw.get("multipleOf"), - (String) raw.get(FORMAT_KEY)); + (String) raw.get(FORMAT_KEY), + extractExtensions(raw)); } @SuppressWarnings("unchecked") @@ -174,7 +187,8 @@ private static ObjectSchema parseObject(Map raw, Set t List.copyOf(required), ap, toIntOrNull(raw.get("minProperties")), - toIntOrNull(raw.get("maxProperties"))); + toIntOrNull(raw.get("maxProperties")), + extractExtensions(raw)); } @SuppressWarnings("unchecked") @@ -192,19 +206,20 @@ private static ArraySchema parseArray(Map raw, Set typ Object itemsRaw = raw.get("items"); Schema itemSchema; if (itemsRaw == null) { - itemSchema = new NullSchema(); + itemSchema = new NullSchema(Map.of()); } else if (itemsRaw instanceof Boolean b) { - itemSchema = b ? new AlwaysSchema() : new NeverSchema(); + itemSchema = b ? new AlwaysSchema(Map.of()) : new NeverSchema(Map.of()); } else { Map items = (Map) itemsRaw; - itemSchema = items.isEmpty() ? new NullSchema() : parse(items); + itemSchema = items.isEmpty() ? new NullSchema(Map.of()) : parse(items); } return new ArraySchema( types, itemSchema, toIntOrNull(raw.get("minItems")), toIntOrNull(raw.get("maxItems")), - Boolean.TRUE.equals(raw.get("uniqueItems"))); + Boolean.TRUE.equals(raw.get("uniqueItems")), + extractExtensions(raw)); } @SuppressWarnings("unchecked") diff --git a/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java b/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java index 47565cd..e022b9b 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java +++ b/src/main/java/com/retailsvc/http/spec/schema/StringSchema.java @@ -1,6 +1,7 @@ package com.retailsvc.http.spec.schema; import java.util.List; +import java.util.Map; import java.util.Set; public record StringSchema( @@ -9,5 +10,6 @@ public record StringSchema( Integer minLength, Integer maxLength, String format, - List enumValues) + List enumValues, + Map extensions) implements Schema {} diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index a6e6ae2..45342fb 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -112,7 +112,7 @@ public void validate(Object value, Schema schema, String pointer) { } switch (schema) { - case RefSchema(String ref) -> validate(value, refResolver.apply(ref), pointer); + case RefSchema(String ref, var _) -> validate(value, refResolver.apply(ref), pointer); case BooleanSchema _ -> validateBoolean(value, pointer); case NullSchema _ -> require(value == null, pointer, "type", "expected null"); case StringSchema s -> validateString(value, s, pointer); @@ -120,18 +120,18 @@ public void validate(Object value, Schema schema, String pointer) { case NumberSchema n -> validateNumber(value, n, pointer); case ObjectSchema o -> validateObject(value, o, pointer); case ArraySchema a -> validateArray(value, a, pointer); - case EnumSchema(List values) -> + case EnumSchema(List values, var _) -> require(values.contains(value), pointer, "enum", "value not in enum"); - case ConstSchema(Object expected) -> + case ConstSchema(Object expected, var _) -> require(Objects.equals(expected, value), pointer, "const", "value does not equal const"); - case AllOfSchema(List parts) -> { + case AllOfSchema(List parts, var _) -> { for (Schema p : parts) { validate(value, p, pointer); } } - case AnyOfSchema(List options) -> validateAnyOf(value, options, pointer); - case OneOfSchema(List options) -> validateOneOf(value, options, pointer); - case NotSchema(Schema inner) -> validateNot(value, inner, pointer); + case AnyOfSchema(List options, var _) -> validateAnyOf(value, options, pointer); + case OneOfSchema(List options, var _) -> validateOneOf(value, options, pointer); + case NotSchema(Schema inner, var _) -> validateNot(value, inner, pointer); case AlwaysSchema _ -> { /* accepts any value, including null */ } diff --git a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java index 07dedfa..614610a 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -137,7 +137,8 @@ void wrongMethodThrowsMethodNotAllowed() { @Test void invalidQueryParamThrowsValidation() { - var stringSchema = new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null); + var stringSchema = + new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null, Map.of()); var op = new Operation( "a", diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java index 9f4ec4a..b5252ba 100644 --- a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -84,4 +84,73 @@ void operationExtensionsExposeXPermissions() { Operation op = spec.operations().getFirst(); assertThat(op.extensions()).containsEntry("x-permissions", List.of("pro.promotion.create")); } + + @Test + void objectSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of( + "Promotion", + Map.of("type", "object", "properties", Map.of(), "x-ui-hint", "card")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Promotion").extensions()) + .containsEntry("x-ui-hint", "card"); + } + + @Test + void stringSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of("schemas", Map.of("Code", Map.of("type", "string", "x-format-hint", "slug")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Code").extensions()) + .containsEntry("x-format-hint", "slug"); + } + + @Test + void oneOfSchemaExtensionsExposeXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of( + "Either", + Map.of( + "oneOf", + List.of(Map.of("type", "string"), Map.of("type", "integer")), + "x-discriminator-hint", + "kind")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Either").extensions()) + .containsEntry("x-discriminator-hint", "kind"); + } } diff --git a/src/test/java/com/retailsvc/http/spec/OperationTest.java b/src/test/java/com/retailsvc/http/spec/OperationTest.java index 6fa3f76..ca0dad1 100644 --- a/src/test/java/com/retailsvc/http/spec/OperationTest.java +++ b/src/test/java/com/retailsvc/http/spec/OperationTest.java @@ -16,7 +16,10 @@ void operationCarriesAllFields() { var path = PathTemplate.compile("/users/{id}"); var param = new Parameter( - "id", Parameter.Location.PATH, true, new BooleanSchema(Set.of(TypeName.BOOLEAN))); + "id", + Parameter.Location.PATH, + true, + new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of())); Operation op = new Operation( "get-user", HttpMethod.GET, path, Optional.empty(), List.of(param), Map.of(), Map.of()); diff --git a/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java b/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java index 272a401..aeef39b 100644 --- a/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java +++ b/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test; class SpecRecordsTest { - private final Schema s = new BooleanSchema(Set.of(TypeName.BOOLEAN)); + private final Schema s = new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); @Test void parameterLocationEnum() { diff --git a/src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java b/src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java index 9185697..a6847ba 100644 --- a/src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java +++ b/src/test/java/com/retailsvc/http/spec/schema/AdditionalPropertiesTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -20,7 +21,7 @@ void forbiddenSentinel() { @Test void schemaConstraintCarriesSchema() { - Schema inner = new BooleanSchema(Set.of(TypeName.BOOLEAN)); + Schema inner = new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); AdditionalProperties ap = new AdditionalProperties.SchemaConstraint(inner); assertThat(((AdditionalProperties.SchemaConstraint) ap).schema()).isSameAs(inner); } diff --git a/src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java b/src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java index c33fb72..c795d77 100644 --- a/src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java +++ b/src/test/java/com/retailsvc/http/spec/schema/CombinatorScaffoldTest.java @@ -3,45 +3,46 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; class CombinatorScaffoldTest { - private final Schema s = new BooleanSchema(Set.of(TypeName.BOOLEAN)); + private final Schema s = new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); @Test void oneOfHoldsOptions() { - assertThat(new OneOfSchema(List.of(s)).options()).hasSize(1); + assertThat(new OneOfSchema(List.of(s), Map.of()).options()).hasSize(1); } @Test void anyOfHoldsOptions() { - assertThat(new AnyOfSchema(List.of(s)).options()).hasSize(1); + assertThat(new AnyOfSchema(List.of(s), Map.of()).options()).hasSize(1); } @Test void allOfHoldsParts() { - assertThat(new AllOfSchema(List.of(s)).parts()).hasSize(1); + assertThat(new AllOfSchema(List.of(s), Map.of()).parts()).hasSize(1); } @Test void notHoldsSchema() { - assertThat(new NotSchema(s).schema()).isSameAs(s); + assertThat(new NotSchema(s, Map.of()).schema()).isSameAs(s); } @Test void constHoldsValue() { - assertThat(new ConstSchema("x").value()).isEqualTo("x"); + assertThat(new ConstSchema("x", Map.of()).value()).isEqualTo("x"); } @Test void enumHoldsValues() { - assertThat(new EnumSchema(List.of(1, 2)).values()).hasSize(2); + assertThat(new EnumSchema(List.of(1, 2), Map.of()).values()).hasSize(2); } @Test void allCombinatorsTypesEmpty() { - assertThat(new OneOfSchema(List.of(s)).types()).isEmpty(); - assertThat(new ConstSchema("x").types()).isEmpty(); + assertThat(new OneOfSchema(List.of(s), Map.of()).types()).isEmpty(); + assertThat(new ConstSchema("x", Map.of()).types()).isEmpty(); } } diff --git a/src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java b/src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java index a2a0428..598e973 100644 --- a/src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java +++ b/src/test/java/com/retailsvc/http/spec/schema/ContainerSchemasTest.java @@ -10,7 +10,7 @@ class ContainerSchemasTest { @Test void objectSchemaCarriesPropertiesAndRequired() { - Schema name = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null); + Schema name = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null, Map.of()); ObjectSchema o = new ObjectSchema( Set.of(TypeName.OBJECT), @@ -18,7 +18,8 @@ void objectSchemaCarriesPropertiesAndRequired() { List.of("name"), new AdditionalProperties.Allowed(), null, - null); + null, + Map.of()); assertThat(o.properties()).containsKey("name"); assertThat(o.required()).containsExactly("name"); assertThat(o.additionalProperties()).isInstanceOf(AdditionalProperties.Allowed.class); @@ -27,8 +28,9 @@ void objectSchemaCarriesPropertiesAndRequired() { @Test void arraySchemaCarriesItemsAndConstraints() { Schema items = - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, "int32"); - ArraySchema a = new ArraySchema(Set.of(TypeName.ARRAY), items, 1, 10, true); + new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, "int32", Map.of()); + ArraySchema a = new ArraySchema(Set.of(TypeName.ARRAY), items, 1, 10, true, Map.of()); assertThat(a.items()).isSameAs(items); assertThat(a.minItems()).isEqualTo(1); assertThat(a.maxItems()).isEqualTo(10); diff --git a/src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java b/src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java index b8a8d70..19b724c 100644 --- a/src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java +++ b/src/test/java/com/retailsvc/http/spec/schema/PrimitiveSchemasTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -10,7 +11,8 @@ class PrimitiveSchemasTest { @Test void stringSchemaCarriesAllStringFields() { StringSchema s = - new StringSchema(Set.of(TypeName.STRING), "^x.*$", 1, 64, "uuid", List.of("a", "b")); + new StringSchema( + Set.of(TypeName.STRING), "^x.*$", 1, 64, "uuid", List.of("a", "b"), Map.of()); assertThat(s.pattern()).isEqualTo("^x.*$"); assertThat(s.minLength()).isEqualTo(1); assertThat(s.maxLength()).isEqualTo(64); @@ -20,7 +22,8 @@ void stringSchemaCarriesAllStringFields() { @Test void numberSchemaCarriesAllNumericConstraints() { - NumberSchema n = new NumberSchema(Set.of(TypeName.NUMBER), 0, 100, null, 100, 5, "double"); + NumberSchema n = + new NumberSchema(Set.of(TypeName.NUMBER), 0, 100, null, 100, 5, "double", Map.of()); assertThat(n.minimum().intValue()).isZero(); assertThat(n.maximum()).isEqualTo(100); assertThat(n.exclusiveMaximum()).isEqualTo(100); @@ -30,19 +33,20 @@ void numberSchemaCarriesAllNumericConstraints() { @Test void integerSchemaUsesLongConstraints() { IntegerSchema i = - new IntegerSchema(Set.of(TypeName.INTEGER), 1L, 2_000_000_000L, null, null, null, "int64"); + new IntegerSchema( + Set.of(TypeName.INTEGER), 1L, 2_000_000_000L, null, null, null, "int64", Map.of()); assertThat(i.maximum()).isEqualTo(2_000_000_000L); assertThat(i.format()).isEqualTo("int64"); } @Test void nullSchemaTypesIsAlwaysNull() { - assertThat(new NullSchema().types()).containsExactly(TypeName.NULL); + assertThat(new NullSchema(Map.of()).types()).containsExactly(TypeName.NULL); } @Test void refSchemaTypesIsEmpty() { - RefSchema r = new RefSchema("#/components/schemas/User"); + RefSchema r = new RefSchema("#/components/schemas/User", Map.of()); assertThat(r.pointer()).isEqualTo("#/components/schemas/User"); assertThat(r.types()).isEmpty(); } diff --git a/src/test/java/com/retailsvc/http/validate/ArrayValidationTest.java b/src/test/java/com/retailsvc/http/validate/ArrayValidationTest.java index bf0f11a..bbf6f71 100644 --- a/src/test/java/com/retailsvc/http/validate/ArrayValidationTest.java +++ b/src/test/java/com/retailsvc/http/validate/ArrayValidationTest.java @@ -10,6 +10,7 @@ import com.retailsvc.http.spec.schema.Schema; import com.retailsvc.http.spec.schema.TypeName; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -21,14 +22,15 @@ class ArrayValidationTest { }); private ArraySchema arr(Schema item, Integer minI, Integer maxI, boolean unique) { - return new ArraySchema(Set.of(TypeName.ARRAY), item, minI, maxI, unique); + return new ArraySchema(Set.of(TypeName.ARRAY), item, minI, maxI, unique, Map.of()); } @Test void itemsValidated() { var s = arr( - new IntegerSchema(Set.of(TypeName.INTEGER), 0L, 100L, null, null, null, "int32"), + new IntegerSchema( + Set.of(TypeName.INTEGER), 0L, 100L, null, null, null, "int32", Map.of()), null, null, false); @@ -40,7 +42,7 @@ void itemsValidated() { @Test void minItemsEnforced() { - var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN)), 2, null, false); + var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()), 2, null, false); assertThatThrownBy(() -> v.validate(List.of(true), s, "")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("minItems"); @@ -48,7 +50,7 @@ void minItemsEnforced() { @Test void maxItemsEnforced() { - var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN)), null, 1, false); + var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()), null, 1, false); assertThatThrownBy(() -> v.validate(List.of(true, false), s, "")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("maxItems"); @@ -58,7 +60,8 @@ void maxItemsEnforced() { void uniqueItemsEnforced() { var s = arr( - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, "int32"), + new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, "int32", Map.of()), null, null, true); @@ -69,7 +72,7 @@ void uniqueItemsEnforced() { @Test void rejectsNonIterable() { - var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN)), null, null, false); + var s = arr(new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()), null, null, false); assertThatThrownBy(() -> v.validate("nope", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("type"); diff --git a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java index 8db6f93..af227fb 100644 --- a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java +++ b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java @@ -28,12 +28,12 @@ class DefaultValidatorDispatchTest { @Test void nullSchemaAcceptsNull() { - v.validate(null, new NullSchema(), ""); + v.validate(null, new NullSchema(Map.of()), ""); } @Test void nullSchemaRejectsNonNull() { - var schema = new NullSchema(); + var schema = new NullSchema(Map.of()); assertThatThrownBy(() -> v.validate("x", schema, "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -42,28 +42,28 @@ void nullSchemaRejectsNonNull() { @Test void booleanSchemaAcceptsBoolean() { - v.validate(true, new BooleanSchema(Set.of(TypeName.BOOLEAN)), "/v"); + v.validate(true, new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()), "/v"); } @Test void booleanSchemaRejectsString() { - var schema = new BooleanSchema(Set.of(TypeName.BOOLEAN)); + var schema = new BooleanSchema(Set.of(TypeName.BOOLEAN), Map.of()); assertThatThrownBy(() -> v.validate("x", schema, "/v")).isInstanceOf(ValidationException.class); } private StringSchema stringSchema(Integer min, Integer max) { - return new StringSchema(Set.of(TypeName.STRING), null, min, max, null, null); + return new StringSchema(Set.of(TypeName.STRING), null, min, max, null, null, Map.of()); } @Test void allOfPassesWhenAllBranchesPass() { - var schema = new AllOfSchema(List.of(stringSchema(1, null), stringSchema(null, 10))); + var schema = new AllOfSchema(List.of(stringSchema(1, null), stringSchema(null, 10)), Map.of()); v.validate("hello", schema, "/v"); } @Test void allOfPropagatesFirstFailingBranch() { - var schema = new AllOfSchema(List.of(stringSchema(1, null), stringSchema(null, 3))); + var schema = new AllOfSchema(List.of(stringSchema(1, null), stringSchema(null, 3)), Map.of()); assertThatThrownBy(() -> v.validate("hello", schema, "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -72,13 +72,14 @@ void allOfPropagatesFirstFailingBranch() { @Test void anyOfPassesWhenOneBranchPasses() { - var schema = new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 10))); + var schema = + new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 10)), Map.of()); v.validate("hello", schema, "/v"); } @Test void anyOfFailsWhenNoBranchMatches() { - var schema = new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2))); + var schema = new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); assertThatThrownBy(() -> v.validate("hello", schema, "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -88,13 +89,14 @@ void anyOfFailsWhenNoBranchMatches() { @Test void oneOfPassesWhenExactlyOneBranchMatches() { // value "hello" — len 5. branch[0] requires min 100 (fails), branch[1] max 10 (passes). - var schema = new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 10))); + var schema = + new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 10)), Map.of()); v.validate("hello", schema, "/v"); } @Test void oneOfFailsWhenZeroBranchesMatch() { - var schema = new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2))); + var schema = new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); assertThatThrownBy(() -> v.validate("hello", schema, "/v")) .isInstanceOf(ValidationException.class) .satisfies( @@ -108,7 +110,7 @@ void oneOfFailsWhenZeroBranchesMatch() { @Test void oneOfFailsWhenTwoBranchesMatch() { // value "hello" — both branches accept. - var schema = new OneOfSchema(List.of(stringSchema(null, 10), stringSchema(1, null))); + var schema = new OneOfSchema(List.of(stringSchema(null, 10), stringSchema(1, null)), Map.of()); assertThatThrownBy(() -> v.validate("hello", schema, "/v")) .isInstanceOf(ValidationException.class) .satisfies( @@ -121,13 +123,13 @@ void oneOfFailsWhenTwoBranchesMatch() { @Test void notPassesWhenInnerFails() { - var schema = new NotSchema(stringSchema(100, null)); + var schema = new NotSchema(stringSchema(100, null), Map.of()); v.validate("hello", schema, "/v"); } @Test void notFailsWhenInnerPasses() { - var schema = new NotSchema(stringSchema(null, 10)); + var schema = new NotSchema(stringSchema(null, 10), Map.of()); assertThatThrownBy(() -> v.validate("hello", schema, "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -137,12 +139,12 @@ void notFailsWhenInnerPasses() { @Test void allOfWithEmptyPartsAlwaysPasses() { // Empty allOf is vacuously true per JSON Schema 2020-12. - v.validate("anything", new AllOfSchema(List.of()), "/v"); + v.validate("anything", new AllOfSchema(List.of(), Map.of()), "/v"); } @Test void anyOfWithEmptyOptionsAlwaysFails() { - assertThatThrownBy(() -> v.validate("anything", new AnyOfSchema(List.of()), "/v")) + assertThatThrownBy(() -> v.validate("anything", new AnyOfSchema(List.of(), Map.of()), "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("anyOf"); @@ -150,7 +152,7 @@ void anyOfWithEmptyOptionsAlwaysFails() { @Test void oneOfWithEmptyOptionsAlwaysFails() { - assertThatThrownBy(() -> v.validate("anything", new OneOfSchema(List.of()), "/v")) + assertThatThrownBy(() -> v.validate("anything", new OneOfSchema(List.of(), Map.of()), "/v")) .isInstanceOf(ValidationException.class) .satisfies( t -> { @@ -163,7 +165,8 @@ void oneOfWithEmptyOptionsAlwaysFails() { @Test void notWithNullSchemaRejectsNull() { // not(NullSchema) — inner accepts null, outer must reject. - assertThatThrownBy(() -> v.validate(null, new NotSchema(new NullSchema()), "/v")) + assertThatThrownBy( + () -> v.validate(null, new NotSchema(new NullSchema(Map.of()), Map.of()), "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("not"); @@ -172,33 +175,34 @@ void notWithNullSchemaRejectsNull() { @Test void anyOfMatchesNullViaNullSchema() { // anyOf containing a NullSchema branch must pass for a null value. - var schema = new AnyOfSchema(List.of(stringSchema(1, null), new NullSchema())); + var schema = + new AnyOfSchema(List.of(stringSchema(1, null), new NullSchema(Map.of())), Map.of()); v.validate(null, schema, "/v"); } @Test void alwaysSchemaAcceptsString() { - v.validate("anything", new AlwaysSchema(), "/v"); + v.validate("anything", new AlwaysSchema(Map.of()), "/v"); } @Test void alwaysSchemaAcceptsInteger() { - v.validate(42, new AlwaysSchema(), "/v"); + v.validate(42, new AlwaysSchema(Map.of()), "/v"); } @Test void alwaysSchemaAcceptsObject() { - v.validate(Map.of("a", 1), new AlwaysSchema(), "/v"); + v.validate(Map.of("a", 1), new AlwaysSchema(Map.of()), "/v"); } @Test void alwaysSchemaAcceptsNull() { - v.validate(null, new AlwaysSchema(), "/v"); + v.validate(null, new AlwaysSchema(Map.of()), "/v"); } @Test void neverSchemaRejectsString() { - assertThatThrownBy(() -> v.validate("anything", new NeverSchema(), "/v")) + assertThatThrownBy(() -> v.validate("anything", new NeverSchema(Map.of()), "/v")) .isInstanceOf(ValidationException.class) .satisfies( t -> { @@ -213,7 +217,7 @@ void neverSchemaRejectsString() { // Full ValidationError surface is verified by neverSchemaRejectsString; these cover keyword only. @Test void neverSchemaRejectsInteger() { - assertThatThrownBy(() -> v.validate(42, new NeverSchema(), "/v")) + assertThatThrownBy(() -> v.validate(42, new NeverSchema(Map.of()), "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("false"); @@ -221,7 +225,7 @@ void neverSchemaRejectsInteger() { @Test void neverSchemaRejectsNull() { - assertThatThrownBy(() -> v.validate(null, new NeverSchema(), "/v")) + assertThatThrownBy(() -> v.validate(null, new NeverSchema(Map.of()), "/v")) .isInstanceOf(ValidationException.class) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("false"); diff --git a/src/test/java/com/retailsvc/http/validate/ObjectValidationTest.java b/src/test/java/com/retailsvc/http/validate/ObjectValidationTest.java index 861d7e8..db378b1 100644 --- a/src/test/java/com/retailsvc/http/validate/ObjectValidationTest.java +++ b/src/test/java/com/retailsvc/http/validate/ObjectValidationTest.java @@ -23,14 +23,16 @@ class ObjectValidationTest { private ObjectSchema obj( Map props, List required, AdditionalProperties ap) { - return new ObjectSchema(Set.of(TypeName.OBJECT), props, required, ap, null, null); + return new ObjectSchema(Set.of(TypeName.OBJECT), props, required, ap, null, null, Map.of()); } @Test void requiredFieldMissing() { var s = obj( - Map.of("name", new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null)), + Map.of( + "name", + new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null, Map.of())), List.of("name"), new AdditionalProperties.Allowed()); var emptyMap = Map.of(); @@ -44,7 +46,9 @@ void requiredFieldMissing() { void propertyValidatedAtPointer() { var s = obj( - Map.of("name", new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null)), + Map.of( + "name", + new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null, Map.of())), List.of(), new AdditionalProperties.Allowed()); assertThatThrownBy(() -> v.validate(Map.of("name", "ab"), s, "")) diff --git a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java index 07407ba..b976366 100644 --- a/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java +++ b/src/test/java/com/retailsvc/http/validate/StringIntegerNumberTest.java @@ -9,6 +9,7 @@ import com.retailsvc.http.spec.schema.StringSchema; import com.retailsvc.http.spec.schema.TypeName; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -22,7 +23,7 @@ class StringIntegerNumberTest { @Test void stringMinLength() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null); + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, 3, null, null, null, Map.of()); assertThatCode(() -> v.validate("abc", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("ab", s, "/v")) .isInstanceOf(ValidationException.class) @@ -32,7 +33,7 @@ void stringMinLength() { @Test void stringMaxLength() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, 5, null, null); + StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, 5, null, null, Map.of()); assertThatThrownBy(() -> v.validate("abcdef", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("maxLength"); @@ -40,7 +41,8 @@ void stringMaxLength() { @Test void stringPattern() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), "^[a-z]+$", null, null, null, null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), "^[a-z]+$", null, null, null, null, Map.of()); assertThatCode(() -> v.validate("abc", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("ABC", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -50,7 +52,8 @@ void stringPattern() { @Test void stringEnum() { StringSchema s = - new StringSchema(Set.of(TypeName.STRING), null, null, null, null, List.of("a", "b")); + new StringSchema( + Set.of(TypeName.STRING), null, null, null, null, List.of("a", "b"), Map.of()); assertThatCode(() -> v.validate("a", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("c", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -59,7 +62,8 @@ void stringEnum() { @Test void stringFormatUuid() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "uuid", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "uuid", null, Map.of()); assertThatCode(() -> v.validate(UUID.randomUUID().toString(), s, "/v")) .doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("not-a-uuid", s, "/v")) @@ -69,7 +73,8 @@ void stringFormatUuid() { @Test void stringFormatEmail() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "email", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "email", null, Map.of()); assertThatCode(() -> v.validate("user@example.com", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("not-an-email", s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -81,7 +86,8 @@ void stringFormatEmail() { @Test void stringFormatUri() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri", null, Map.of()); assertThatCode(() -> v.validate("https://example.com/path", s, "/v")) .doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("/relative/path", s, "/v")) @@ -95,7 +101,8 @@ void stringFormatUri() { @Test void stringFormatUriReference() { StringSchema s = - new StringSchema(Set.of(TypeName.STRING), null, null, null, "uri-reference", null); + new StringSchema( + Set.of(TypeName.STRING), null, null, null, "uri-reference", null, Map.of()); assertThatCode(() -> v.validate("https://example.com", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("/relative/path", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("ht tp://broken", s, "/v")) @@ -105,7 +112,8 @@ void stringFormatUriReference() { @Test void stringFormatHostname() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "hostname", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "hostname", null, Map.of()); assertThatCode(() -> v.validate("example.com", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("a.b.c.example", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("-leading-hyphen.com", s, "/v")) @@ -118,7 +126,8 @@ void stringFormatHostname() { @Test void stringFormatIpv4() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv4", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv4", null, Map.of()); assertThatCode(() -> v.validate("192.168.0.1", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("0.0.0.0", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("255.255.255.255", s, "/v")).doesNotThrowAnyException(); @@ -135,7 +144,8 @@ void stringFormatIpv4() { @Test void stringFormatIpv6() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv6", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "ipv6", null, Map.of()); assertThatCode(() -> v.validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334", s, "/v")) .doesNotThrowAnyException(); assertThatCode(() -> v.validate("2001:db8::1", s, "/v")).doesNotThrowAnyException(); @@ -151,7 +161,7 @@ void stringFormatIpv6() { @Test void integerWithMinMax() { IntegerSchema s = - new IntegerSchema(Set.of(TypeName.INTEGER), 0L, 10L, null, null, null, "int32"); + new IntegerSchema(Set.of(TypeName.INTEGER), 0L, 10L, null, null, null, "int32", Map.of()); assertThatCode(() -> v.validate(5, s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate(-1, s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -166,14 +176,15 @@ void integerExclusiveBoundsBugFixedFromMaster() { // Master's Schema defaulted minimum to Double.MIN_VALUE (~4.9e-324) and silently rejected // negative numbers. New model uses null = no constraint. IntegerSchema s = - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, "int32"); + new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, "int32", Map.of()); assertThatCode(() -> v.validate(-1_000_000, s, "/v")).doesNotThrowAnyException(); } @Test void integerMultipleOf() { IntegerSchema s = - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, 5L, "int32"); + new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, 5L, "int32", Map.of()); assertThatCode(() -> v.validate(15, s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate(7, s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) @@ -182,7 +193,8 @@ void integerMultipleOf() { @Test void numberAcceptsDoublesAndIntegers() { - NumberSchema s = new NumberSchema(Set.of(TypeName.NUMBER), 0, 1, null, null, null, "double"); + NumberSchema s = + new NumberSchema(Set.of(TypeName.NUMBER), 0, 1, null, null, null, "double", Map.of()); assertThatCode(() -> v.validate(0.5, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(1, s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate(2.0, s, "/v")) @@ -192,7 +204,8 @@ void numberAcceptsDoublesAndIntegers() { @Test void stringFormatRegex() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "regex", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "regex", null, Map.of()); assertThatCode(() -> v.validate("^[a-z]+$", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("\\d{3}-\\d{4}", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("[invalid", s, "/v")) @@ -202,7 +215,8 @@ void stringFormatRegex() { @Test void stringFormatByte() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "byte", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "byte", null, Map.of()); assertThatCode(() -> v.validate("aGVsbG8=", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate("", s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate("not base64!!", s, "/v")) @@ -215,20 +229,23 @@ void stringFormatByte() { @Test void stringFormatBinaryAcceptsAnyString() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "binary", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "binary", null, Map.of()); assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(" ", s, "/v")).doesNotThrowAnyException(); } @Test void stringFormatPasswordAcceptsAnyString() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, "password", null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, "password", null, Map.of()); assertThatCode(() -> v.validate("anything goes", s, "/v")).doesNotThrowAnyException(); } @Test void stringRejectsNonString() { - StringSchema s = new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null); + StringSchema s = + new StringSchema(Set.of(TypeName.STRING), null, null, null, null, null, Map.of()); assertThatThrownBy(() -> v.validate(42, s, "/v")) .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("type"); @@ -238,14 +255,15 @@ void stringRejectsNonString() { void stringFormatUnknownIsIgnored() { StringSchema s = new StringSchema( - Set.of(TypeName.STRING), null, null, null, "definitely-not-a-format", null); + Set.of(TypeName.STRING), null, null, null, "definitely-not-a-format", null, Map.of()); assertThatCode(() -> v.validate("anything", s, "/v")).doesNotThrowAnyException(); } @Test void integerFormatInt64AcceptsAnyLong() { IntegerSchema s = - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, "int64"); + new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, "int64", Map.of()); assertThatCode(() -> v.validate(Long.MAX_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(Long.MIN_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(0L, s, "/v")).doesNotThrowAnyException(); @@ -255,7 +273,8 @@ void integerFormatInt64AcceptsAnyLong() { @Test void integerFormatInt32() { IntegerSchema s = - new IntegerSchema(Set.of(TypeName.INTEGER), null, null, null, null, null, "int32"); + new IntegerSchema( + Set.of(TypeName.INTEGER), null, null, null, null, null, "int32", Map.of()); assertThatCode(() -> v.validate(Integer.MAX_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(Integer.MIN_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatThrownBy(() -> v.validate(Integer.MAX_VALUE + 1L, s, "/v")) @@ -269,7 +288,7 @@ void integerFormatInt32() { @Test void numberFormatDoubleAcceptsAnyDouble() { NumberSchema s = - new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, "double"); + new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, "double", Map.of()); assertThatCode(() -> v.validate(Double.MAX_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(-Double.MAX_VALUE, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(0.0, s, "/v")).doesNotThrowAnyException(); @@ -279,7 +298,7 @@ void numberFormatDoubleAcceptsAnyDouble() { @Test void numberFormatFloat() { NumberSchema s = - new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, "float"); + new NumberSchema(Set.of(TypeName.NUMBER), null, null, null, null, null, "float", Map.of()); assertThatCode(() -> v.validate(1.5, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate(-1.5, s, "/v")).doesNotThrowAnyException(); assertThatCode(() -> v.validate((double) Float.MAX_VALUE, s, "/v")).doesNotThrowAnyException(); @@ -301,7 +320,14 @@ void numberFormatFloat() { void integerFormatUnknownIsIgnored() { IntegerSchema s = new IntegerSchema( - Set.of(TypeName.INTEGER), null, null, null, null, null, "definitely-not-a-format"); + Set.of(TypeName.INTEGER), + null, + null, + null, + null, + null, + "definitely-not-a-format", + Map.of()); assertThatCode(() -> v.validate(42L, s, "/v")).doesNotThrowAnyException(); } @@ -309,7 +335,14 @@ void integerFormatUnknownIsIgnored() { void numberFormatUnknownIsIgnored() { NumberSchema s = new NumberSchema( - Set.of(TypeName.NUMBER), null, null, null, null, null, "definitely-not-a-format"); + Set.of(TypeName.NUMBER), + null, + null, + null, + null, + null, + "definitely-not-a-format", + Map.of()); assertThatCode(() -> v.validate(1.5, s, "/v")).doesNotThrowAnyException(); } } From 283a1709a686cebb48fd7a08a18a2a2681410dc1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 15:01:03 +0200 Subject: [PATCH 7/8] test: Verify x-permissions flows through fixture parse --- .../com/retailsvc/http/spec/ExtensionsTest.java | 17 +++++++++++++++++ src/test/resources/openapi.json | 1 + src/test/resources/openapi.yaml | 2 ++ 3 files changed, 20 insertions(+) diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java index b5252ba..f546626 100644 --- a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.gson.Gson; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -127,6 +128,22 @@ void stringSchemaExtensionsExposeXKeys() { .containsEntry("x-format-hint", "slug"); } + @Test + @SuppressWarnings("unchecked") + void fixtureOperationExtensionsAreReadable() throws Exception { + Gson gson = new Gson(); + String text = + new String(ExtensionsTest.class.getResourceAsStream("/openapi.json").readAllBytes()); + Map raw = (Map) gson.fromJson(text, Map.class); + Spec spec = Spec.from(raw); + Operation op = + spec.operations().stream() + .filter(o -> "post-data".equals(o.operationId())) + .findFirst() + .orElseThrow(); + assertThat(op.extensions()).containsEntry("x-permissions", List.of("pro.promotion.create")); + } + @Test void oneOfSchemaExtensionsExposeXKeys() { Map raw = diff --git a/src/test/resources/openapi.json b/src/test/resources/openapi.json index bf91c4b..111314f 100644 --- a/src/test/resources/openapi.json +++ b/src/test/resources/openapi.json @@ -41,6 +41,7 @@ }, "post": { "operationId": "post-data", + "x-permissions": ["pro.promotion.create"], "requestBody": { "content": { "application/json": { diff --git a/src/test/resources/openapi.yaml b/src/test/resources/openapi.yaml index b91d782..f9eeaf4 100644 --- a/src/test/resources/openapi.yaml +++ b/src/test/resources/openapi.yaml @@ -28,6 +28,8 @@ paths: post: operationId: post-data + x-permissions: + - pro.promotion.create requestBody: content: application/json: From 6d3d2f50c358486123cd9796d6f0cabea0399032 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 11 May 2026 15:05:35 +0200 Subject: [PATCH 8/8] fix: Preserve x-* on permissive object and multi-assertion AllOfSchema wrapper When a schema map has only x-* keys (no type, no combinators, no shape keywords), the synthesized permissive ObjectSchema previously discarded them. Likewise, when parseMap collapsed multiple assertions into a wrapper AllOfSchema, the wrapper carried Map.of() instead of the top-level extensions. Both call sites now pass extractExtensions(raw). --- .../http/spec/schema/SchemaParser.java | 8 ++-- .../retailsvc/http/spec/ExtensionsTest.java | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java index bb9df3a..7bfd905 100644 --- a/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java +++ b/src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java @@ -61,9 +61,9 @@ private static Schema parseMap(Map raw) { } return switch (assertions.size()) { - case 0 -> permissiveObject(); + case 0 -> permissiveObject(extractExtensions(raw)); case 1 -> assertions.getFirst(); - default -> new AllOfSchema(List.copyOf(assertions), Map.of()); + default -> new AllOfSchema(List.copyOf(assertions), extractExtensions(raw)); }; } @@ -115,9 +115,9 @@ private static boolean hasArrayShapeKeywords(Map raw) { || raw.containsKey("uniqueItems"); } - private static Schema permissiveObject() { + private static Schema permissiveObject(Map extensions) { return new ObjectSchema( - Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null, Map.of()); + Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null, extensions); } private static Set parseTypes(Map raw) { diff --git a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java index f546626..c4b81ab 100644 --- a/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -170,4 +170,52 @@ void oneOfSchemaExtensionsExposeXKeys() { assertThat(spec.componentSchemas().get("Either").extensions()) .containsEntry("x-discriminator-hint", "kind"); } + + @Test + void permissiveObjectPreservesXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of("schemas", Map.of("FreeForm", Map.of("x-vendor", "acme")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("FreeForm").extensions()) + .containsEntry("x-vendor", "acme"); + } + + @Test + void multiAssertionWrapperPreservesXKeys() { + Map raw = + Map.of( + "openapi", + "3.1.0", + "info", + Map.of("title", "t", "version", "1"), + "servers", + List.of(Map.of("url", "https://example.com")), + "paths", + Map.of(), + "components", + Map.of( + "schemas", + Map.of( + "Composite", + Map.of( + "type", + "object", + "anyOf", + List.of(Map.of("type", "object"), Map.of("type", "object")), + "x-tag", + "composite")))); + Spec spec = Spec.from(raw); + assertThat(spec.componentSchemas().get("Composite").extensions()) + .containsEntry("x-tag", "composite"); + } }