Skip to content

Commit a0f2822

Browse files
thcedclaude
andcommitted
docs: Add combinators design spec
Specifies validator and parser changes to implement OpenAPI 3.1 allOf / anyOf / oneOf / not, including composition with sibling base assertions via implicit AllOf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c48958 commit a0f2822

1 file changed

Lines changed: 110 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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

Comments
 (0)