|
| 1 | +# OpenAPI 3.1 Combinators — Design |
| 2 | + |
| 3 | +**Date:** 2026-05-08 |
| 4 | +**Status:** Approved |
| 5 | +**Predecessor:** `2026-05-07-openapi-refactor-design.md` (Section 9, Wave 1 #3 and partial #4) |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Implement runtime validation for the four JSON Schema 2020-12 combinator keywords used in OpenAPI 3.1: `allOf`, `anyOf`, `oneOf`, `not`. The schema records, sealed interface, and parser already produce the corresponding `AllOfSchema` / `AnyOfSchema` / `OneOfSchema` / `NotSchema` records; `DefaultValidator` currently throws `UnsupportedOperationException` for each. Replace those branches with real validation, and update the parser so combinators can co-exist with sibling base assertions (`type`, `properties`, `required`, etc.) per JSON Schema semantics. |
| 10 | + |
| 11 | +## Decisions |
| 12 | + |
| 13 | +1. **Composition with sibling assertions.** When a schema map contains both a combinator keyword and base assertions, the parser emits an implicit `AllOfSchema` whose elements are the parsed base schema and each combinator. This matches JSON Schema 2020-12's "all keywords at one level are conjoined" rule. |
| 14 | +2. **Error reporting.** Fail-fast with a single `ValidationError`. For `oneOf` / `anyOf` / `not`, emit a generic message keyed by the combinator name. For `allOf`, propagate the first failing branch's `ValidationError` unchanged. |
| 15 | +3. **Evaluation strategy.** Internal evaluation is dictated by semantics — `anyOf` short-circuits on first match, `oneOf` must evaluate every branch to count matches, `allOf` short-circuits on first failure, `not` runs its inner schema once. Branch failures are captured by catching `ValidationException`; this is a deliberate control-flow use of exceptions, not an error-handling pattern. |
| 16 | +4. **Out of scope (deferred).** Schema booleans (`true` / `false` as a bare schema), `discriminator`, and multi-error collection. These remain on the gap inventory (Wave 1 #4 partial, Wave 6 #25, #26). |
| 17 | + |
| 18 | +## Validator |
| 19 | + |
| 20 | +Replace the four UOE branches in `DefaultValidator.validate(...)`: |
| 21 | + |
| 22 | +```java |
| 23 | +case AllOfSchema(List<Schema> parts) -> { |
| 24 | + for (Schema p : parts) validate(value, p, pointer); |
| 25 | +} |
| 26 | + |
| 27 | +case AnyOfSchema(List<Schema> options) -> { |
| 28 | + for (Schema o : options) { |
| 29 | + try { validate(value, o, pointer); return; } |
| 30 | + catch (ValidationException ignored) { /* try next */ } |
| 31 | + } |
| 32 | + fail(pointer, "anyOf", "did not match any anyOf branch", value); |
| 33 | +} |
| 34 | + |
| 35 | +case OneOfSchema(List<Schema> options) -> { |
| 36 | + int matched = 0; |
| 37 | + for (Schema o : options) { |
| 38 | + try { validate(value, o, pointer); matched++; } |
| 39 | + catch (ValidationException ignored) { /* count misses */ } |
| 40 | + } |
| 41 | + if (matched != 1) { |
| 42 | + fail(pointer, "oneOf", |
| 43 | + "matched " + matched + " of " + options.size() + " oneOf branches", value); |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +case NotSchema(Schema inner) -> { |
| 48 | + try { validate(value, inner, pointer); } |
| 49 | + catch (ValidationException expected) { return; } |
| 50 | + fail(pointer, "not", "value matched 'not' schema", value); |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +The pointer for combinator failures is the schema's pointer (the spot where the combinator is declared) — same convention as other keyword failures. Sub-branch errors do not carry through; the outer message is intentionally generic. Multi-error collection (Wave 6 #25) will revisit this. |
| 55 | + |
| 56 | +## Parser |
| 57 | + |
| 58 | +Currently `SchemaParser.parse(...)` dispatches in priority order — `$ref` → combinator → `const`/`enum` → `type` → permissive object — and emits exactly one record. The change: when a combinator coexists with sibling base assertions, build an `AllOfSchema` whose first element is the parsed base and whose remaining elements are the combinators. |
| 59 | + |
| 60 | +Pseudocode: |
| 61 | + |
| 62 | +```java |
| 63 | +if (raw has $ref) return RefSchema(...); |
| 64 | + |
| 65 | +List<Schema> assertions = new ArrayList<>(); |
| 66 | +Schema base = parseBaseIfPresent(raw); // existing const/enum/type/object dispatch; null if absent |
| 67 | +if (base != null) assertions.add(base); |
| 68 | + |
| 69 | +if (raw has allOf) assertions.addAll(parseAll(raw.allOf)); // flatten one level |
| 70 | +if (raw has anyOf) assertions.add(new AnyOfSchema(parseAll(raw.anyOf))); |
| 71 | +if (raw has oneOf) assertions.add(new OneOfSchema(parseAll(raw.oneOf))); |
| 72 | +if (raw has not) assertions.add(new NotSchema(parse(raw.not))); |
| 73 | + |
| 74 | +return switch (assertions.size()) { |
| 75 | + case 0 -> permissiveObject; |
| 76 | + case 1 -> assertions.get(0); |
| 77 | + default -> new AllOfSchema(List.copyOf(assertions)); |
| 78 | +}; |
| 79 | +``` |
| 80 | + |
| 81 | +`allOf` branches flatten directly into the outer assertions list since `AllOf(AllOf(a, b), c)` is semantically equal to `AllOf(a, b, c)`. `anyOf` and `oneOf` are not flattened because their semantics differ from `AllOf`. `parseBaseIfPresent` returns `null` when the schema map has no base-assertion keywords (`type`, `const`, `enum`, or any of the object/array shape keywords) so a vacuous permissive-object isn't injected. |
| 82 | + |
| 83 | +`$ref` continues to be parsed solo. JSON Schema 2020-12 allows siblings to `$ref`, but that interaction is a separate gap; not addressed here. |
| 84 | + |
| 85 | +## Tests |
| 86 | + |
| 87 | +- **Parser, combinator alone:** existing tests for `OneOfSchema` / `AnyOfSchema` / `AllOfSchema` / `NotSchema` round-trip remain green. |
| 88 | +- **Parser, combinator + sibling `type`:** new tests asserting the result is `AllOfSchema([base, combinator])`. One per combinator. Covers the primary correctness goal of decision (1). |
| 89 | +- **Parser, multiple combinators in one schema:** e.g. `{anyOf: [...], not: ...}` → `AllOfSchema([AnyOfSchema(...), NotSchema(...)])`. |
| 90 | +- **Validator, happy path:** one passing test per combinator (`oneOf` exactly one match, `anyOf` first branch matches, `allOf` all branches pass, `not` inner fails so outer passes). |
| 91 | +- **Validator, failure path:** `oneOf` zero matches and two-plus matches (separate tests, asserting the count in the message); `anyOf` no match; `allOf` second branch fails (asserts the inner pointer/message propagates); `not` inner passes. |
| 92 | +- **Validator, combinator + sibling type:** one polymorphic-body test driven by the parser composition path. |
| 93 | +- **Integration:** extend the test fixture (`src/test/resources/openapi.{yaml,json}`) with one operation whose request body uses `oneOf` for a discriminated-style polymorphic shape (no `discriminator` keyword — that's deferred). Add a test handler and assert success on a valid body and 400 on a body that matches zero or two branches. The fixture twins (yaml & json) stay in sync per the existing memory entry. |
| 94 | +- **Performance:** k6 smoke run against the example launcher confirms no regression. The combinator dispatch adds a single `case` per request, the catch-blocks only run on combinator paths, and the test schema's existing routes don't use combinators — so the broad k6 numbers should be unchanged. |
| 95 | + |
| 96 | +## Risk and rollback |
| 97 | + |
| 98 | +- **Performance of try/catch in `oneOf`/`anyOf`:** for branches that fail validation, we throw and catch a `ValidationException`. This is fine for typical request volumes but is more allocation-heavy than a boolean predicate. Risk is bounded — combinators are not on the hot path for our existing test fixture. If a future spec uses combinators in tight loops, an internal `boolean tryValidate(...)` overload is an additive optimization. |
| 99 | +- **Parser regression:** the dispatch change touches every schema parse. Mitigation: existing parser tests (combinator alone, primitives, refs) keep running and pin behaviour. |
| 100 | +- **Rollback:** the change is contained in `SchemaParser` and `DefaultValidator`. Reverting is one revert per file. |
| 101 | + |
| 102 | +## Sequencing |
| 103 | + |
| 104 | +Single PR. Suggested commit shape: |
| 105 | + |
| 106 | +1. Validator: replace UOE branches with real validation; add validator unit tests. |
| 107 | +2. Parser: composition path; add parser unit tests for combinator + sibling. |
| 108 | +3. Integration: fixture extension + end-to-end test. |
| 109 | + |
| 110 | +Each commit verifiable with `mvn -q verify`. |
0 commit comments