Skip to content

Commit 4fd7f68

Browse files
authored
feat: Implement combinators (#42)
1 parent 4c48958 commit 4fd7f68

9 files changed

Lines changed: 1706 additions & 36 deletions

File tree

docs/superpowers/plans/2026-05-08-combinators-implementation.md

Lines changed: 775 additions & 0 deletions
Large diffs are not rendered by default.
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`.

src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,35 @@ public static Schema parse(Map<String, Object> raw) {
1818
return new RefSchema((String) raw.get("$ref"));
1919
}
2020

21-
if (raw.containsKey("oneOf")) {
22-
return new OneOfSchema(parseList(raw, "oneOf"));
21+
List<Schema> assertions = new ArrayList<>();
22+
23+
Schema base = parseBaseIfPresent(raw);
24+
if (base != null) {
25+
assertions.add(base);
26+
}
27+
28+
if (raw.containsKey("allOf")) {
29+
assertions.addAll(parseList(raw, "allOf"));
2330
}
2431
if (raw.containsKey("anyOf")) {
25-
return new AnyOfSchema(parseList(raw, "anyOf"));
32+
assertions.add(new AnyOfSchema(parseList(raw, "anyOf")));
2633
}
27-
if (raw.containsKey("allOf")) {
28-
return new AllOfSchema(parseList(raw, "allOf"));
34+
if (raw.containsKey("oneOf")) {
35+
assertions.add(new OneOfSchema(parseList(raw, "oneOf")));
2936
}
3037
if (raw.containsKey("not")) {
31-
return new NotSchema(parse((Map<String, Object>) raw.get("not")));
38+
assertions.add(new NotSchema(parse((Map<String, Object>) raw.get("not"))));
3239
}
40+
41+
return switch (assertions.size()) {
42+
case 0 -> permissiveObject();
43+
case 1 -> assertions.getFirst();
44+
default -> new AllOfSchema(List.copyOf(assertions));
45+
};
46+
}
47+
48+
@SuppressWarnings("unchecked")
49+
private static Schema parseBaseIfPresent(Map<String, Object> raw) {
3350
if (raw.containsKey("const")) {
3451
return new ConstSchema(raw.get("const"));
3552
}
@@ -38,11 +55,18 @@ public static Schema parse(Map<String, Object> raw) {
3855
}
3956

4057
Set<TypeName> types = parseTypes(raw);
58+
if (types.isEmpty() && !hasObjectShapeKeywords(raw) && !hasArrayShapeKeywords(raw)) {
59+
return null;
60+
}
61+
if (types.isEmpty() && hasObjectShapeKeywords(raw)) {
62+
return parseObject(raw, types);
63+
}
64+
if (types.isEmpty() && hasArrayShapeKeywords(raw)) {
65+
return parseArray(raw, types);
66+
}
4167

42-
// Pick primary (non-null) type for record dispatch.
4368
TypeName primary =
4469
types.stream().filter(t -> t != TypeName.NULL).findFirst().orElse(TypeName.NULL);
45-
4670
return switch (primary) {
4771
case STRING -> parseString(raw, types);
4872
case INTEGER -> parseInteger(raw, types);
@@ -54,6 +78,26 @@ public static Schema parse(Map<String, Object> raw) {
5478
};
5579
}
5680

81+
private static boolean hasObjectShapeKeywords(Map<String, Object> raw) {
82+
return raw.containsKey("properties")
83+
|| raw.containsKey("required")
84+
|| raw.containsKey("additionalProperties")
85+
|| raw.containsKey("minProperties")
86+
|| raw.containsKey("maxProperties");
87+
}
88+
89+
private static boolean hasArrayShapeKeywords(Map<String, Object> raw) {
90+
return raw.containsKey("items")
91+
|| raw.containsKey("minItems")
92+
|| raw.containsKey("maxItems")
93+
|| raw.containsKey("uniqueItems");
94+
}
95+
96+
private static Schema permissiveObject() {
97+
return new ObjectSchema(
98+
Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null);
99+
}
100+
57101
private static Set<TypeName> parseTypes(Map<String, Object> raw) {
58102
Object t = raw.get("type");
59103
EnumSet<TypeName> out = EnumSet.noneOf(TypeName.class);

src/main/java/com/retailsvc/http/validate/DefaultValidator.java

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,14 @@ case EnumSchema(List<Object> values) ->
6464
require(values.contains(value), pointer, "enum", "value not in enum");
6565
case ConstSchema(Object expected) ->
6666
require(Objects.equals(expected, value), pointer, "const", "value does not equal const");
67-
case OneOfSchema _ -> throw new UnsupportedOperationException("oneOf not yet supported");
68-
case AnyOfSchema _ -> throw new UnsupportedOperationException("anyOf not yet supported");
69-
case AllOfSchema _ -> throw new UnsupportedOperationException("allOf not yet supported");
70-
case NotSchema _ -> throw new UnsupportedOperationException("not not yet supported");
67+
case AllOfSchema(List<Schema> parts) -> {
68+
for (Schema p : parts) {
69+
validate(value, p, pointer);
70+
}
71+
}
72+
case AnyOfSchema(List<Schema> options) -> validateAnyOf(value, options, pointer);
73+
case OneOfSchema(List<Schema> options) -> validateOneOf(value, options, pointer);
74+
case NotSchema(Schema inner) -> validateNot(value, inner, pointer);
7175
}
7276
}
7377

@@ -290,4 +294,44 @@ static void require(boolean condition, String pointer, String keyword, String me
290294
throw new ValidationException(new ValidationError(pointer, keyword, message, null));
291295
}
292296
}
297+
298+
private void validateAnyOf(Object value, List<Schema> options, String pointer) {
299+
for (Schema o : options) {
300+
try {
301+
validate(value, o, pointer);
302+
return;
303+
} catch (ValidationException ignored) {
304+
// try next branch
305+
}
306+
}
307+
fail(pointer, "anyOf", "did not match any anyOf branch", value);
308+
}
309+
310+
private void validateOneOf(Object value, List<Schema> options, String pointer) {
311+
int matched = 0;
312+
for (Schema o : options) {
313+
try {
314+
validate(value, o, pointer);
315+
matched++;
316+
} catch (ValidationException ignored) {
317+
// branch did not match; continue
318+
}
319+
}
320+
if (matched != 1) {
321+
fail(
322+
pointer,
323+
"oneOf",
324+
"matched " + matched + " of " + options.size() + " oneOf branches",
325+
value);
326+
}
327+
}
328+
329+
private void validateNot(Object value, Schema inner, String pointer) {
330+
try {
331+
validate(value, inner, pointer);
332+
} catch (ValidationException expected) {
333+
return;
334+
}
335+
fail(pointer, "not", "value matched 'not' schema", value);
336+
}
293337
}

0 commit comments

Comments
 (0)