Skip to content

Commit 1358915

Browse files
authored
feat: Implement JSON Schema boolean schemas (true/false) (#43)
1 parent 4fd7f68 commit 1358915

13 files changed

Lines changed: 942 additions & 18 deletions

File tree

docs/superpowers/plans/2026-05-08-schema-booleans-implementation.md

Lines changed: 589 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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`.

src/main/java/com/retailsvc/http/spec/Spec.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ private static Map<String, Schema> parseComponentSchemas(Map<String, Object> raw
104104
(Map<String, Object>) rawComponents.getOrDefault("schemas", Map.of());
105105
Map<String, Schema> out = new LinkedHashMap<>();
106106
for (var e : rawSchemas.entrySet()) {
107-
out.put(e.getKey(), SchemaParser.parse((Map<String, Object>) e.getValue()));
107+
out.put(e.getKey(), SchemaParser.parse(e.getValue()));
108108
}
109109
return Map.copyOf(out);
110110
}
@@ -127,8 +127,7 @@ private static Parameter parseParameter(Map<String, Object> raw) {
127127
(String) raw.get("name"),
128128
Parameter.Location.valueOf(((String) raw.get("in")).toUpperCase(Locale.ROOT)),
129129
Boolean.TRUE.equals(raw.get("required")),
130-
SchemaParser.parse(
131-
(Map<String, Object>) raw.getOrDefault(SCHEMA_KEY, Map.of("type", "string"))));
130+
SchemaParser.parse(raw.getOrDefault(SCHEMA_KEY, Map.of("type", "string"))));
132131
}
133132

134133
@SuppressWarnings("unchecked")
@@ -193,9 +192,7 @@ private static RequestBody parseRequestBody(Map<String, Object> raw) {
193192
Map<String, Object> mt = (Map<String, Object>) e.getValue();
194193
content.put(
195194
e.getKey(),
196-
new MediaType(
197-
SchemaParser.parse(
198-
(Map<String, Object>) mt.getOrDefault(SCHEMA_KEY, Map.of("type", "object")))));
195+
new MediaType(SchemaParser.parse(mt.getOrDefault(SCHEMA_KEY, Map.of("type", "object")))));
199196
}
200197
return new RequestBody(Boolean.TRUE.equals(raw.get("required")), Map.copyOf(content));
201198
}
@@ -210,9 +207,7 @@ private static Map<String, Response> parseResponses(Map<String, Object> raw) {
210207
for (var ce : contentRaw.entrySet()) {
211208
Map<String, Object> mt = (Map<String, Object>) ce.getValue();
212209
if (mt.containsKey(SCHEMA_KEY)) {
213-
content.put(
214-
ce.getKey(),
215-
new MediaType(SchemaParser.parse((Map<String, Object>) mt.get(SCHEMA_KEY))));
210+
content.put(ce.getKey(), new MediaType(SchemaParser.parse(mt.get(SCHEMA_KEY))));
216211
}
217212
}
218213
out.put(e.getKey(), new Response(Map.copyOf(content)));
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.retailsvc.http.spec.schema;
2+
3+
import java.util.Set;
4+
5+
public record AlwaysSchema() implements Schema {
6+
@Override
7+
public Set<TypeName> types() {
8+
return Set.of();
9+
}
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.retailsvc.http.spec.schema;
2+
3+
import java.util.Set;
4+
5+
public record NeverSchema() implements Schema {
6+
@Override
7+
public Set<TypeName> types() {
8+
return Set.of();
9+
}
10+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public sealed interface Schema
1616
AllOfSchema,
1717
NotSchema,
1818
ConstSchema,
19-
EnumSchema {
19+
EnumSchema,
20+
AlwaysSchema,
21+
NeverSchema {
2022
Set<TypeName> types();
2123
}

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@ private SchemaParser() {}
1212

1313
private static final String FORMAT_KEY = "format";
1414

15+
public static Schema parse(Object raw) {
16+
if (raw instanceof Boolean b) {
17+
return b ? new AlwaysSchema() : new NeverSchema();
18+
}
19+
if (raw instanceof Map<?, ?> map) {
20+
@SuppressWarnings("unchecked")
21+
Map<String, Object> typed = (Map<String, Object>) map;
22+
return parseMap(typed);
23+
}
24+
throw new IllegalArgumentException("schema must be a boolean or an object, was: " + raw);
25+
}
26+
1527
@SuppressWarnings("unchecked")
16-
public static Schema parse(Map<String, Object> raw) {
28+
private static Schema parseMap(Map<String, Object> raw) {
1729
if (raw.containsKey("$ref")) {
1830
return new RefSchema((String) raw.get("$ref"));
1931
}
@@ -35,7 +47,7 @@ public static Schema parse(Map<String, Object> raw) {
3547
assertions.add(new OneOfSchema(parseList(raw, "oneOf")));
3648
}
3749
if (raw.containsKey("not")) {
38-
assertions.add(new NotSchema(parse((Map<String, Object>) raw.get("not"))));
50+
assertions.add(new NotSchema(parse(raw.get("not"))));
3951
}
4052

4153
return switch (assertions.size()) {
@@ -152,7 +164,7 @@ private static ObjectSchema parseObject(Map<String, Object> raw, Set<TypeName> t
152164
Map<String, Object> rawProps = (Map<String, Object>) raw.getOrDefault("properties", Map.of());
153165
Map<String, Schema> properties = new LinkedHashMap<>();
154166
for (var e : rawProps.entrySet()) {
155-
properties.put(e.getKey(), parse((Map<String, Object>) e.getValue()));
167+
properties.put(e.getKey(), parse(e.getValue()));
156168
}
157169
List<String> required = (List<String>) raw.getOrDefault("required", List.of());
158170
AdditionalProperties ap = parseAdditionalProperties(raw.get("additionalProperties"));
@@ -171,14 +183,22 @@ private static AdditionalProperties parseAdditionalProperties(Object value) {
171183
case null -> new AdditionalProperties.Allowed();
172184
case Boolean b when b -> new AdditionalProperties.Allowed();
173185
case Boolean _ -> new AdditionalProperties.Forbidden();
174-
default -> new AdditionalProperties.SchemaConstraint(parse((Map<String, Object>) value));
186+
default -> new AdditionalProperties.SchemaConstraint(parse(value));
175187
};
176188
}
177189

178190
@SuppressWarnings("unchecked")
179191
private static ArraySchema parseArray(Map<String, Object> raw, Set<TypeName> types) {
180-
Map<String, Object> items = (Map<String, Object>) raw.getOrDefault("items", Map.of());
181-
Schema itemSchema = items.isEmpty() ? new NullSchema() : parse(items);
192+
Object itemsRaw = raw.get("items");
193+
Schema itemSchema;
194+
if (itemsRaw == null) {
195+
itemSchema = new NullSchema();
196+
} else if (itemsRaw instanceof Boolean b) {
197+
itemSchema = b ? new AlwaysSchema() : new NeverSchema();
198+
} else {
199+
Map<String, Object> items = (Map<String, Object>) itemsRaw;
200+
itemSchema = items.isEmpty() ? new NullSchema() : parse(items);
201+
}
182202
return new ArraySchema(
183203
types,
184204
itemSchema,
@@ -189,9 +209,9 @@ private static ArraySchema parseArray(Map<String, Object> raw, Set<TypeName> typ
189209

190210
@SuppressWarnings("unchecked")
191211
private static List<Schema> parseList(Map<String, Object> raw, String key) {
192-
List<Map<String, Object>> raws = (List<Map<String, Object>>) raw.get(key);
212+
List<Object> raws = (List<Object>) raw.get(key);
193213
List<Schema> out = new ArrayList<>(raws.size());
194-
for (Map<String, Object> r : raws) {
214+
for (Object r : raws) {
195215
out.add(parse(r));
196216
}
197217
return List.copyOf(out);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import com.retailsvc.http.ValidationException;
44
import com.retailsvc.http.spec.schema.AdditionalProperties;
55
import com.retailsvc.http.spec.schema.AllOfSchema;
6+
import com.retailsvc.http.spec.schema.AlwaysSchema;
67
import com.retailsvc.http.spec.schema.AnyOfSchema;
78
import com.retailsvc.http.spec.schema.ArraySchema;
89
import com.retailsvc.http.spec.schema.BooleanSchema;
910
import com.retailsvc.http.spec.schema.ConstSchema;
1011
import com.retailsvc.http.spec.schema.EnumSchema;
1112
import com.retailsvc.http.spec.schema.IntegerSchema;
13+
import com.retailsvc.http.spec.schema.NeverSchema;
1214
import com.retailsvc.http.spec.schema.NotSchema;
1315
import com.retailsvc.http.spec.schema.NullSchema;
1416
import com.retailsvc.http.spec.schema.NumberSchema;
@@ -72,6 +74,10 @@ case AllOfSchema(List<Schema> parts) -> {
7274
case AnyOfSchema(List<Schema> options) -> validateAnyOf(value, options, pointer);
7375
case OneOfSchema(List<Schema> options) -> validateOneOf(value, options, pointer);
7476
case NotSchema(Schema inner) -> validateNot(value, inner, pointer);
77+
case AlwaysSchema _ -> {
78+
/* accepts any value, including null */
79+
}
80+
case NeverSchema _ -> fail(pointer, "false", "schema rejects all values", value);
7581
}
7682
}
7783

src/test/java/com/retailsvc/http/OpenApiServerIT.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,51 @@ void postBlockedForbiddenTokenReturns400() {
598598
}
599599
}
600600
}
601+
602+
@Nested
603+
class Gates {
604+
605+
String path = "/gates";
606+
607+
@Test
608+
void postGateBodyWithOnlyOpenReturns200() {
609+
try (var server = newServer(Map.of("post-gate", new EchoHandler()));
610+
var client = httpClient()) {
611+
var body = "{\"open\":\"anything\"}";
612+
var request = newRequest(server, path, "POST", ofString(body));
613+
614+
var response = client.send(request, BodyHandlers.ofString());
615+
616+
assertThat(response.statusCode()).isEqualTo(200);
617+
} catch (IOException e) {
618+
fail(e);
619+
} catch (InterruptedException e) {
620+
Thread.currentThread().interrupt();
621+
fail(e);
622+
}
623+
}
624+
625+
@Test
626+
void postGateBodyWithBlockedReturns400() {
627+
try (var server = newServer(Map.of("post-gate", new EchoHandler()));
628+
var client = httpClient()) {
629+
// Any value in 'blocked' triggers the false-schema rejection,
630+
// because NeverSchema rejects every value.
631+
var body = "{\"open\":\"x\",\"blocked\":\"anything\"}";
632+
var request = newRequest(server, path, "POST", ofString(body));
633+
634+
var response = client.send(request, BodyHandlers.ofString());
635+
636+
assertThat(response.statusCode()).isEqualTo(400);
637+
assertThat(response.headers().firstValue("Content-Type").orElse(""))
638+
.contains("application/problem+json");
639+
assertThat(response.body()).contains("\"keyword\":\"false\"");
640+
} catch (IOException e) {
641+
fail(e);
642+
} catch (InterruptedException e) {
643+
Thread.currentThread().interrupt();
644+
fail(e);
645+
}
646+
}
647+
}
601648
}

src/test/java/com/retailsvc/http/spec/schema/SchemaParserTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.retailsvc.http.spec.schema;
22

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
45

56
import java.util.List;
67
import java.util.Map;
@@ -391,4 +392,53 @@ void oneOfContainingNestedAnyOfRecurses() {
391392
assertThat(((AnyOfSchema) one.options().get(0)).options()).hasSize(2);
392393
assertThat(one.options().get(1)).isInstanceOf(BooleanSchema.class);
393394
}
395+
396+
@Test
397+
void parsesTrueAsAlwaysSchema() {
398+
assertThat(SchemaParser.parse(Boolean.TRUE)).isInstanceOf(AlwaysSchema.class);
399+
}
400+
401+
@Test
402+
void parsesFalseAsNeverSchema() {
403+
assertThat(SchemaParser.parse(Boolean.FALSE)).isInstanceOf(NeverSchema.class);
404+
}
405+
406+
@Test
407+
void rejectsNonMapNonBooleanRawSchema() {
408+
assertThatThrownBy(() -> SchemaParser.parse("oops"))
409+
.isInstanceOf(IllegalArgumentException.class)
410+
.hasMessageContaining("schema must be a boolean or an object");
411+
}
412+
413+
@Test
414+
void parsesObjectWithBooleanPropertySchemas() {
415+
Schema s =
416+
SchemaParser.parse(
417+
Map.of("type", "object", "properties", Map.of("x", Boolean.TRUE, "y", Boolean.FALSE)));
418+
assertThat(s).isInstanceOf(ObjectSchema.class);
419+
ObjectSchema obj = (ObjectSchema) s;
420+
assertThat(obj.properties().get("x")).isInstanceOf(AlwaysSchema.class);
421+
assertThat(obj.properties().get("y")).isInstanceOf(NeverSchema.class);
422+
}
423+
424+
@Test
425+
void parsesArrayWithBooleanItemsSchema() {
426+
Schema s = SchemaParser.parse(Map.of("type", "array", "items", Boolean.TRUE));
427+
assertThat(s).isInstanceOf(ArraySchema.class);
428+
assertThat(((ArraySchema) s).items()).isInstanceOf(AlwaysSchema.class);
429+
}
430+
431+
@Test
432+
void parsesArrayWithBooleanFalseItems() {
433+
Schema s = SchemaParser.parse(Map.of("type", "array", "items", Boolean.FALSE));
434+
assertThat(s).isInstanceOf(ArraySchema.class);
435+
assertThat(((ArraySchema) s).items()).isInstanceOf(NeverSchema.class);
436+
}
437+
438+
@Test
439+
void rejectsNullRawSchema() {
440+
assertThatThrownBy(() -> SchemaParser.parse(null))
441+
.isInstanceOf(IllegalArgumentException.class)
442+
.hasMessageContaining("schema must be a boolean or an object");
443+
}
394444
}

0 commit comments

Comments
 (0)