Skip to content

Commit 4417cba

Browse files
thcedclaude
andcommitted
feat: Parse boolean schemas as AlwaysSchema and NeverSchema
JSON Schema 2020-12 allows a bare true or false where a schema is expected: true accepts any value, false rejects every value. Add the two new sealed-hierarchy records and change SchemaParser.parse to accept Object so boolean values dispatch to the new records. Recursive callers (parseObject, parseArray, parseList, NotSchema) and external callers in Spec.java drop their Map casts. AdditionalProperties keeps its existing Boolean handling. DefaultValidator gets minimal switch branches for the two new types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 09bf201 commit 4417cba

7 files changed

Lines changed: 97 additions & 18 deletions

File tree

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/spec/schema/SchemaParserTest.java

Lines changed: 36 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,39 @@ 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+
}
394430
}

0 commit comments

Comments
 (0)