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. 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. 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/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 a6a3bde..276cce1 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) { @@ -88,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) { @@ -167,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/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..7bfd905 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 0 -> permissiveObject(extractExtensions(raw)); case 1 -> assertions.getFirst(); - default -> new AllOfSchema(List.copyOf(assertions)); + default -> new AllOfSchema(List.copyOf(assertions), extractExtensions(raw)); }; } @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); }; @@ -105,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); + Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null, extensions); } 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 a4b888b..614610a 100644 --- a/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/RequestPreparationFilterTest.java @@ -45,13 +45,14 @@ 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(), Map.of(), "", Map.of(), + Map.of(), Map.of()); } @@ -70,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); @@ -105,6 +107,7 @@ void unknownPathThrowsNotFound() { PathTemplate.compile("/x"), Optional.empty(), List.of(), + Map.of(), Map.of())); Filter f = newFilter(spec); @@ -123,6 +126,7 @@ void wrongMethodThrowsMethodNotAllowed() { PathTemplate.compile("/x"), Optional.empty(), List.of(), + Map.of(), Map.of())); Filter f = newFilter(spec); @@ -133,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", @@ -141,6 +146,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 new file mode 100644 index 0000000..c4b81ab --- /dev/null +++ b/src/test/java/com/retailsvc/http/spec/ExtensionsTest.java @@ -0,0 +1,221 @@ +package com.retailsvc.http.spec; + +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; + +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(); + } + + @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"); + } + + @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")); + } + + @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 + @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 = + 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"); + } + + @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"); + } +} diff --git a/src/test/java/com/retailsvc/http/spec/OperationTest.java b/src/test/java/com/retailsvc/http/spec/OperationTest.java index f2479b7..ca0dad1 100644 --- a/src/test/java/com/retailsvc/http/spec/OperationTest.java +++ b/src/test/java/com/retailsvc/http/spec/OperationTest.java @@ -16,9 +16,13 @@ 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()); + 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); diff --git a/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java b/src/test/java/com/retailsvc/http/spec/SpecRecordsTest.java index 5f13e3f..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() { @@ -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"); } 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(); } } 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: