Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
775 changes: 775 additions & 0 deletions docs/superpowers/plans/2026-05-08-combinators-implementation.md

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions docs/superpowers/specs/2026-05-08-combinators-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# OpenAPI 3.1 Combinators — Design

**Date:** 2026-05-08
**Status:** Approved
**Predecessor:** `2026-05-07-openapi-refactor-design.md` (Section 9, Wave 1 #3 and partial #4)

## Goal

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.

## Decisions

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.
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.
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.
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).

## Validator

Replace the four UOE branches in `DefaultValidator.validate(...)`:

```java
case AllOfSchema(List<Schema> parts) -> {
for (Schema p : parts) validate(value, p, pointer);
}

case AnyOfSchema(List<Schema> options) -> {
for (Schema o : options) {
try { validate(value, o, pointer); return; }
catch (ValidationException ignored) { /* try next */ }
}
fail(pointer, "anyOf", "did not match any anyOf branch", value);
}

case OneOfSchema(List<Schema> options) -> {
int matched = 0;
for (Schema o : options) {
try { validate(value, o, pointer); matched++; }
catch (ValidationException ignored) { /* count misses */ }
}
if (matched != 1) {
fail(pointer, "oneOf",
"matched " + matched + " of " + options.size() + " oneOf branches", value);
}
}

case NotSchema(Schema inner) -> {
try { validate(value, inner, pointer); }
catch (ValidationException expected) { return; }
fail(pointer, "not", "value matched 'not' schema", value);
}
```

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.

## Parser

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.

Pseudocode:

```java
if (raw has $ref) return RefSchema(...);

List<Schema> assertions = new ArrayList<>();
Schema base = parseBaseIfPresent(raw); // existing const/enum/type/object dispatch; null if absent
if (base != null) assertions.add(base);

if (raw has allOf) assertions.addAll(parseAll(raw.allOf)); // flatten one level
if (raw has anyOf) assertions.add(new AnyOfSchema(parseAll(raw.anyOf)));
if (raw has oneOf) assertions.add(new OneOfSchema(parseAll(raw.oneOf)));
if (raw has not) assertions.add(new NotSchema(parse(raw.not)));

return switch (assertions.size()) {
case 0 -> permissiveObject;
case 1 -> assertions.get(0);
default -> new AllOfSchema(List.copyOf(assertions));
};
```

`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.

`$ref` continues to be parsed solo. JSON Schema 2020-12 allows siblings to `$ref`, but that interaction is a separate gap; not addressed here.

## Tests

- **Parser, combinator alone:** existing tests for `OneOfSchema` / `AnyOfSchema` / `AllOfSchema` / `NotSchema` round-trip remain green.
- **Parser, combinator + sibling `type`:** new tests asserting the result is `AllOfSchema([base, combinator])`. One per combinator. Covers the primary correctness goal of decision (1).
- **Parser, multiple combinators in one schema:** e.g. `{anyOf: [...], not: ...}` → `AllOfSchema([AnyOfSchema(...), NotSchema(...)])`.
- **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).
- **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.
- **Validator, combinator + sibling type:** one polymorphic-body test driven by the parser composition path.
- **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.
- **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.

## Risk and rollback

- **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.
- **Parser regression:** the dispatch change touches every schema parse. Mitigation: existing parser tests (combinator alone, primitives, refs) keep running and pin behaviour.
- **Rollback:** the change is contained in `SchemaParser` and `DefaultValidator`. Reverting is one revert per file.

## Sequencing

Single PR. Suggested commit shape:

1. Validator: replace UOE branches with real validation; add validator unit tests.
2. Parser: composition path; add parser unit tests for combinator + sibling.
3. Integration: fixture extension + end-to-end test.

Each commit verifiable with `mvn -q verify`.
60 changes: 52 additions & 8 deletions src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,35 @@ public static Schema parse(Map<String, Object> raw) {
return new RefSchema((String) raw.get("$ref"));
}

if (raw.containsKey("oneOf")) {
return new OneOfSchema(parseList(raw, "oneOf"));
List<Schema> assertions = new ArrayList<>();

Schema base = parseBaseIfPresent(raw);
if (base != null) {
assertions.add(base);
}

if (raw.containsKey("allOf")) {
assertions.addAll(parseList(raw, "allOf"));
}
if (raw.containsKey("anyOf")) {
return new AnyOfSchema(parseList(raw, "anyOf"));
assertions.add(new AnyOfSchema(parseList(raw, "anyOf")));
}
if (raw.containsKey("allOf")) {
return new AllOfSchema(parseList(raw, "allOf"));
if (raw.containsKey("oneOf")) {
assertions.add(new OneOfSchema(parseList(raw, "oneOf")));
}
if (raw.containsKey("not")) {
return new NotSchema(parse((Map<String, Object>) raw.get("not")));
assertions.add(new NotSchema(parse((Map<String, Object>) raw.get("not"))));
}

return switch (assertions.size()) {
case 0 -> permissiveObject();
case 1 -> assertions.getFirst();
default -> new AllOfSchema(List.copyOf(assertions));
};
}

@SuppressWarnings("unchecked")
private static Schema parseBaseIfPresent(Map<String, Object> raw) {
if (raw.containsKey("const")) {
return new ConstSchema(raw.get("const"));
}
Expand All @@ -38,11 +55,18 @@ public static Schema parse(Map<String, Object> raw) {
}

Set<TypeName> types = parseTypes(raw);
if (types.isEmpty() && !hasObjectShapeKeywords(raw) && !hasArrayShapeKeywords(raw)) {
return null;
}
if (types.isEmpty() && hasObjectShapeKeywords(raw)) {
return parseObject(raw, types);
}
if (types.isEmpty() && hasArrayShapeKeywords(raw)) {
return parseArray(raw, types);
}

// Pick primary (non-null) type for record dispatch.
TypeName primary =
types.stream().filter(t -> t != TypeName.NULL).findFirst().orElse(TypeName.NULL);

return switch (primary) {
case STRING -> parseString(raw, types);
case INTEGER -> parseInteger(raw, types);
Expand All @@ -54,6 +78,26 @@ public static Schema parse(Map<String, Object> raw) {
};
}

private static boolean hasObjectShapeKeywords(Map<String, Object> raw) {
return raw.containsKey("properties")
|| raw.containsKey("required")
|| raw.containsKey("additionalProperties")
|| raw.containsKey("minProperties")
|| raw.containsKey("maxProperties");
}

private static boolean hasArrayShapeKeywords(Map<String, Object> raw) {
return raw.containsKey("items")
|| raw.containsKey("minItems")
|| raw.containsKey("maxItems")
|| raw.containsKey("uniqueItems");
}

private static Schema permissiveObject() {
return new ObjectSchema(
Set.of(), Map.of(), List.of(), new AdditionalProperties.Allowed(), null, null);
}

private static Set<TypeName> parseTypes(Map<String, Object> raw) {
Object t = raw.get("type");
EnumSet<TypeName> out = EnumSet.noneOf(TypeName.class);
Expand Down
52 changes: 48 additions & 4 deletions src/main/java/com/retailsvc/http/validate/DefaultValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@
require(values.contains(value), pointer, "enum", "value not in enum");
case ConstSchema(Object expected) ->
require(Objects.equals(expected, value), pointer, "const", "value does not equal const");
case OneOfSchema _ -> throw new UnsupportedOperationException("oneOf not yet supported");
case AnyOfSchema _ -> throw new UnsupportedOperationException("anyOf not yet supported");
case AllOfSchema _ -> throw new UnsupportedOperationException("allOf not yet supported");
case NotSchema _ -> throw new UnsupportedOperationException("not not yet supported");
case AllOfSchema(List<Schema> parts) -> {
for (Schema p : parts) {
validate(value, p, pointer);
}
}
case AnyOfSchema(List<Schema> options) -> validateAnyOf(value, options, pointer);
case OneOfSchema(List<Schema> options) -> validateOneOf(value, options, pointer);
case NotSchema(Schema inner) -> validateNot(value, inner, pointer);
}
}

Expand Down Expand Up @@ -290,4 +294,44 @@
throw new ValidationException(new ValidationError(pointer, keyword, message, null));
}
}

private void validateAnyOf(Object value, List<Schema> options, String pointer) {
for (Schema o : options) {
try {
validate(value, o, pointer);
return;
} catch (ValidationException ignored) {

Check warning on line 303 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log or rethrow this exception.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTwx&open=AZ4Ht8ZlTM3WtTVJTTwx&pullRequest=42

Check warning on line 303 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "ignored" with an unnamed pattern.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTw0&open=AZ4Ht8ZlTM3WtTVJTTw0&pullRequest=42
// try next branch
}
}
fail(pointer, "anyOf", "did not match any anyOf branch", value);
}

private void validateOneOf(Object value, List<Schema> options, String pointer) {
int matched = 0;
for (Schema o : options) {
try {
validate(value, o, pointer);
matched++;
} catch (ValidationException ignored) {

Check warning on line 316 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "ignored" with an unnamed pattern.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTw1&open=AZ4Ht8ZlTM3WtTVJTTw1&pullRequest=42

Check warning on line 316 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log or rethrow this exception.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTwy&open=AZ4Ht8ZlTM3WtTVJTTwy&pullRequest=42
// branch did not match; continue
}
}
if (matched != 1) {
fail(
pointer,
"oneOf",
"matched " + matched + " of " + options.size() + " oneOf branches",
value);
}
}

private void validateNot(Object value, Schema inner, String pointer) {
try {
validate(value, inner, pointer);
} catch (ValidationException expected) {

Check warning on line 332 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log or rethrow this exception.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTwz&open=AZ4Ht8ZlTM3WtTVJTTwz&pullRequest=42

Check warning on line 332 in src/main/java/com/retailsvc/http/validate/DefaultValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "expected" with an unnamed pattern.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4Ht8ZlTM3WtTVJTTw2&open=AZ4Ht8ZlTM3WtTVJTTw2&pullRequest=42
return;
}
fail(pointer, "not", "value matched 'not' schema", value);
}
}
Loading
Loading