|
| 1 | +# Schema Booleans — Design |
| 2 | + |
| 3 | +**Date:** 2026-05-08 |
| 4 | +**Status:** Approved |
| 5 | +**Predecessor:** `2026-05-07-openapi-refactor-design.md` (Section 9, Wave 1 #4 partial) |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Support JSON Schema 2020-12 boolean schemas in OpenAPI 3.1: a bare `true` or `false` where a schema is expected. `true` accepts any value; `false` rejects any value. The remaining items from Wave 1 #4 (`not`, `const`, top-level `enum`) are already implemented; this spec covers only the boolean-schema piece. |
| 10 | + |
| 11 | +## Decisions |
| 12 | + |
| 13 | +1. **Two new schema records.** `AlwaysSchema` and `NeverSchema` join the sealed `Schema` hierarchy. Names mirror JSON Schema's "always-accepting" / "never-accepting" terminology and let the validator switch read like the spec text. |
| 14 | +2. **Parser entry signature change.** `SchemaParser.parse` becomes `parse(Object)` instead of `parse(Map<String, Object>)`. Callers (internal recursive calls and external callers in `Spec.java`) drop the `Map` cast. `AdditionalProperties` keeps its existing Boolean handling — it already converts `true`/`false` to `Allowed` / `Forbidden` before reaching `parse`. |
| 15 | +3. **Validator behaviour.** `AlwaysSchema` is a no-op pass (including for `null`); `NeverSchema` always fails with keyword `"false"` and message `"schema rejects all values"`. |
| 16 | +4. **Out of scope.** Pre-existing array-items empty-map quirk; `$ref` siblings; combinator branches accepting booleans (depends on `feat/combinators` merging — once it does, the parser change here automatically covers `oneOf: [true]` etc.). |
| 17 | + |
| 18 | +## Schema records |
| 19 | + |
| 20 | +```java |
| 21 | +public record AlwaysSchema() implements Schema { |
| 22 | + public Set<TypeName> types() { return Set.of(); } |
| 23 | +} |
| 24 | + |
| 25 | +public record NeverSchema() implements Schema { |
| 26 | + public Set<TypeName> types() { return Set.of(); } |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +`Schema.java`'s `permits` clause grows by two. `types()` returns empty per the convention used by combinator / ref / const / enum records. The top-level `null` short-circuit in `DefaultValidator.validate(...)` checks `schema.types().contains(NULL)`, so `null` falls through to the switch — which is what we want: `AlwaysSchema` accepts `null` via its case body, `NeverSchema` rejects `null` via its case body. |
| 31 | + |
| 32 | +## Parser |
| 33 | + |
| 34 | +`SchemaParser.parse` switches its parameter type from `Map<String, Object>` to `Object`, with a single dispatch added at the top: |
| 35 | + |
| 36 | +```java |
| 37 | +public static Schema parse(Object raw) { |
| 38 | + if (raw instanceof Boolean b) { |
| 39 | + return b ? new AlwaysSchema() : new NeverSchema(); |
| 40 | + } |
| 41 | + if (raw instanceof Map<?, ?> map) { |
| 42 | + @SuppressWarnings("unchecked") |
| 43 | + Map<String, Object> typed = (Map<String, Object>) map; |
| 44 | + return parseMap(typed); |
| 45 | + } |
| 46 | + throw new IllegalArgumentException("schema must be a boolean or an object, was: " + raw); |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +`parseMap` is the existing body of the old `parse` method, renamed. Internal recursive calls (`parseObject` for property values, `parseArray` for `items`, `parseList` for combinator branches once `feat/combinators` lands) drop the cast: `parse(value)` instead of `parse((Map<String, Object>) value)`. |
| 51 | + |
| 52 | +External callers in `src/main/java/com/retailsvc/http/spec/Spec.java` (`parseComponentSchemas`, `parseParameter`, `parseRequestBody`, `parseResponses`) similarly drop their `(Map<String, Object>)` casts on the argument passed to `parse`. |
| 53 | + |
| 54 | +`AdditionalProperties` keeps its current implementation — it dispatches on `null` / `Boolean` / `Map` before constructing a `SchemaConstraint`, so no Boolean ever reaches `parse` from that path. Leaving it alone preserves the existing `AdditionalProperties.Allowed` / `Forbidden` records. |
| 55 | + |
| 56 | +## Validator |
| 57 | + |
| 58 | +Two new branches in the `switch` in `DefaultValidator.validate(...)`: |
| 59 | + |
| 60 | +```java |
| 61 | +case AlwaysSchema _ -> { /* accepts any value, including null */ } |
| 62 | +case NeverSchema _ -> fail(pointer, "false", "schema rejects all values", value); |
| 63 | +``` |
| 64 | + |
| 65 | +Pointer is the schema's pointer, matching the convention used for combinator failures. Keyword `"false"` describes the source schema literal that produced the failure. |
| 66 | + |
| 67 | +## Tests |
| 68 | + |
| 69 | +- **Parser unit tests** (`SchemaParserTest`): |
| 70 | + - `parse(Boolean.TRUE)` returns `AlwaysSchema`. |
| 71 | + - `parse(Boolean.FALSE)` returns `NeverSchema`. |
| 72 | + - `parse` of a non-Map / non-Boolean input throws `IllegalArgumentException` with the message format documented above. |
| 73 | + - `parse` of an object whose `properties.x: true` and `properties.y: false` produces an `ObjectSchema` whose two property values are `AlwaysSchema` and `NeverSchema` respectively. |
| 74 | +- **Validator unit tests** (`DefaultValidatorDispatchTest`): |
| 75 | + - `AlwaysSchema` accepts a string, an integer, an object map, and `null` (single test exercising several values, or four small tests — implementer's choice). |
| 76 | + - `NeverSchema` rejects every value with keyword `"false"` and message containing `"rejects all values"`. Cover at least: a string, an integer, `null`. |
| 77 | +- **Integration test:** extend `src/test/resources/openapi.{yaml,json}` (twins kept in sync per the existing memory entry) with one path — say `/gates` — whose request body schema is: |
| 78 | + ```yaml |
| 79 | + type: object |
| 80 | + required: [open] |
| 81 | + properties: |
| 82 | + open: true # accepted regardless of type |
| 83 | + blocked: false # any presence rejects the body |
| 84 | + ``` |
| 85 | + Two new IT tests in `OpenApiServerIT`: |
| 86 | + - Body containing only `open` (any JSON value) → 200. |
| 87 | + - Body containing `blocked` (any value) → 400 with content-type `application/problem+json` and body containing `"false"`. |
| 88 | + |
| 89 | +## Risk and rollback |
| 90 | + |
| 91 | +- **Parser API break.** The `parse(Map)` → `parse(Object)` signature change is binary-incompatible. The library has no published consumers (`0.0.1-local`), so this is acceptable. Internal callers and tests are all updated in the same PR. |
| 92 | +- **Empty-map `items` interaction.** `parseArray` continues to short-circuit `items.isEmpty()` to `NullSchema`. With the new parser, `items: true` would correctly produce `AlwaysSchema` since the input is a Boolean, not a Map. The empty-map edge case is unaffected and remains a pre-existing quirk to be cleaned up separately. |
| 93 | +- **Rollback.** Two new records, one parser signature change, two validator cases — straightforward to revert per file. |
| 94 | + |
| 95 | +## Sequencing |
| 96 | + |
| 97 | +Single PR, three commits: |
| 98 | + |
| 99 | +1. `feat`: Schema records (`AlwaysSchema`, `NeverSchema`) + parser entry change + parser unit tests. |
| 100 | +2. `feat`: Validator branches + validator unit tests. |
| 101 | +3. `test`: Integration fixture extension (`/gates`) + end-to-end tests. |
| 102 | + |
| 103 | +Each commit verifiable with `mvn -q verify`. |
0 commit comments