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
589 changes: 589 additions & 0 deletions docs/superpowers/plans/2026-05-08-schema-booleans-implementation.md

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions docs/superpowers/specs/2026-05-08-schema-booleans-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Schema Booleans — Design

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

## Goal

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.

## Decisions

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.
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`.
3. **Validator behaviour.** `AlwaysSchema` is a no-op pass (including for `null`); `NeverSchema` always fails with keyword `"false"` and message `"schema rejects all values"`.
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.).

## Schema records

```java
public record AlwaysSchema() implements Schema {
public Set<TypeName> types() { return Set.of(); }
}

public record NeverSchema() implements Schema {
public Set<TypeName> types() { return Set.of(); }
}
```

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

## Parser

`SchemaParser.parse` switches its parameter type from `Map<String, Object>` to `Object`, with a single dispatch added at the top:

```java
public static Schema parse(Object raw) {
if (raw instanceof Boolean b) {
return b ? new AlwaysSchema() : new NeverSchema();
}
if (raw instanceof Map<?, ?> map) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) map;
return parseMap(typed);
}
throw new IllegalArgumentException("schema must be a boolean or an object, was: " + raw);
}
```

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

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

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

## Validator

Two new branches in the `switch` in `DefaultValidator.validate(...)`:

```java
case AlwaysSchema _ -> { /* accepts any value, including null */ }
case NeverSchema _ -> fail(pointer, "false", "schema rejects all values", value);
```

Pointer is the schema's pointer, matching the convention used for combinator failures. Keyword `"false"` describes the source schema literal that produced the failure.

## Tests

- **Parser unit tests** (`SchemaParserTest`):
- `parse(Boolean.TRUE)` returns `AlwaysSchema`.
- `parse(Boolean.FALSE)` returns `NeverSchema`.
- `parse` of a non-Map / non-Boolean input throws `IllegalArgumentException` with the message format documented above.
- `parse` of an object whose `properties.x: true` and `properties.y: false` produces an `ObjectSchema` whose two property values are `AlwaysSchema` and `NeverSchema` respectively.
- **Validator unit tests** (`DefaultValidatorDispatchTest`):
- `AlwaysSchema` accepts a string, an integer, an object map, and `null` (single test exercising several values, or four small tests — implementer's choice).
- `NeverSchema` rejects every value with keyword `"false"` and message containing `"rejects all values"`. Cover at least: a string, an integer, `null`.
- **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:
```yaml
type: object
required: [open]
properties:
open: true # accepted regardless of type
blocked: false # any presence rejects the body
```
Two new IT tests in `OpenApiServerIT`:
- Body containing only `open` (any JSON value) → 200.
- Body containing `blocked` (any value) → 400 with content-type `application/problem+json` and body containing `"false"`.

## Risk and rollback

- **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.
- **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.
- **Rollback.** Two new records, one parser signature change, two validator cases — straightforward to revert per file.

## Sequencing

Single PR, three commits:

1. `feat`: Schema records (`AlwaysSchema`, `NeverSchema`) + parser entry change + parser unit tests.
2. `feat`: Validator branches + validator unit tests.
3. `test`: Integration fixture extension (`/gates`) + end-to-end tests.

Each commit verifiable with `mvn -q verify`.
13 changes: 4 additions & 9 deletions src/main/java/com/retailsvc/http/spec/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ private static Map<String, Schema> parseComponentSchemas(Map<String, Object> raw
(Map<String, Object>) rawComponents.getOrDefault("schemas", Map.of());
Map<String, Schema> out = new LinkedHashMap<>();
for (var e : rawSchemas.entrySet()) {
out.put(e.getKey(), SchemaParser.parse((Map<String, Object>) e.getValue()));
out.put(e.getKey(), SchemaParser.parse(e.getValue()));
}
return Map.copyOf(out);
}
Expand All @@ -127,8 +127,7 @@ private static Parameter parseParameter(Map<String, Object> raw) {
(String) raw.get("name"),
Parameter.Location.valueOf(((String) raw.get("in")).toUpperCase(Locale.ROOT)),
Boolean.TRUE.equals(raw.get("required")),
SchemaParser.parse(
(Map<String, Object>) raw.getOrDefault(SCHEMA_KEY, Map.of("type", "string"))));
SchemaParser.parse(raw.getOrDefault(SCHEMA_KEY, Map.of("type", "string"))));
}

@SuppressWarnings("unchecked")
Expand Down Expand Up @@ -193,9 +192,7 @@ private static RequestBody parseRequestBody(Map<String, Object> raw) {
Map<String, Object> mt = (Map<String, Object>) e.getValue();
content.put(
e.getKey(),
new MediaType(
SchemaParser.parse(
(Map<String, Object>) mt.getOrDefault(SCHEMA_KEY, Map.of("type", "object")))));
new MediaType(SchemaParser.parse(mt.getOrDefault(SCHEMA_KEY, Map.of("type", "object")))));
}
return new RequestBody(Boolean.TRUE.equals(raw.get("required")), Map.copyOf(content));
}
Expand All @@ -210,9 +207,7 @@ private static Map<String, Response> parseResponses(Map<String, Object> raw) {
for (var ce : contentRaw.entrySet()) {
Map<String, Object> mt = (Map<String, Object>) ce.getValue();
if (mt.containsKey(SCHEMA_KEY)) {
content.put(
ce.getKey(),
new MediaType(SchemaParser.parse((Map<String, Object>) mt.get(SCHEMA_KEY))));
content.put(ce.getKey(), new MediaType(SchemaParser.parse(mt.get(SCHEMA_KEY))));
}
}
out.put(e.getKey(), new Response(Map.copyOf(content)));
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/retailsvc/http/spec/schema/AlwaysSchema.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.Set;

public record AlwaysSchema() implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/retailsvc/http/spec/schema/NeverSchema.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.retailsvc.http.spec.schema;

import java.util.Set;

public record NeverSchema() implements Schema {
@Override
public Set<TypeName> types() {
return Set.of();
}
}
4 changes: 3 additions & 1 deletion src/main/java/com/retailsvc/http/spec/schema/Schema.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public sealed interface Schema
AllOfSchema,
NotSchema,
ConstSchema,
EnumSchema {
EnumSchema,
AlwaysSchema,
NeverSchema {
Set<TypeName> types();
}
36 changes: 28 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 @@ -12,8 +12,20 @@

private static final String FORMAT_KEY = "format";

public static Schema parse(Object raw) {
if (raw instanceof Boolean b) {
return b ? new AlwaysSchema() : new NeverSchema();

Check warning on line 17 in src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a primitive boolean expression here.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4H4KAcCmMGJffq-7nN&open=AZ4H4KAcCmMGJffq-7nN&pullRequest=43
}
if (raw instanceof Map<?, ?> map) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) map;
return parseMap(typed);
}
throw new IllegalArgumentException("schema must be a boolean or an object, was: " + raw);
}

@SuppressWarnings("unchecked")
public static Schema parse(Map<String, Object> raw) {
private static Schema parseMap(Map<String, Object> raw) {
if (raw.containsKey("$ref")) {
return new RefSchema((String) raw.get("$ref"));
}
Expand All @@ -35,7 +47,7 @@
assertions.add(new OneOfSchema(parseList(raw, "oneOf")));
}
if (raw.containsKey("not")) {
assertions.add(new NotSchema(parse((Map<String, Object>) raw.get("not"))));
assertions.add(new NotSchema(parse(raw.get("not"))));
}

return switch (assertions.size()) {
Expand Down Expand Up @@ -152,7 +164,7 @@
Map<String, Object> rawProps = (Map<String, Object>) raw.getOrDefault("properties", Map.of());
Map<String, Schema> properties = new LinkedHashMap<>();
for (var e : rawProps.entrySet()) {
properties.put(e.getKey(), parse((Map<String, Object>) e.getValue()));
properties.put(e.getKey(), parse(e.getValue()));
}
List<String> required = (List<String>) raw.getOrDefault("required", List.of());
AdditionalProperties ap = parseAdditionalProperties(raw.get("additionalProperties"));
Expand All @@ -171,14 +183,22 @@
case null -> new AdditionalProperties.Allowed();
case Boolean b when b -> new AdditionalProperties.Allowed();
case Boolean _ -> new AdditionalProperties.Forbidden();
default -> new AdditionalProperties.SchemaConstraint(parse((Map<String, Object>) value));
default -> new AdditionalProperties.SchemaConstraint(parse(value));
};
}

@SuppressWarnings("unchecked")
private static ArraySchema parseArray(Map<String, Object> raw, Set<TypeName> types) {
Map<String, Object> items = (Map<String, Object>) raw.getOrDefault("items", Map.of());
Schema itemSchema = items.isEmpty() ? new NullSchema() : parse(items);
Object itemsRaw = raw.get("items");
Schema itemSchema;
if (itemsRaw == null) {
itemSchema = new NullSchema();
} else if (itemsRaw instanceof Boolean b) {
itemSchema = b ? new AlwaysSchema() : new NeverSchema();

Check warning on line 197 in src/main/java/com/retailsvc/http/spec/schema/SchemaParser.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a primitive boolean expression here.

See more on https://sonarcloud.io/project/issues?id=extenda_openapi-httpserver-java&issues=AZ4H4KAcCmMGJffq-7nO&open=AZ4H4KAcCmMGJffq-7nO&pullRequest=43
} else {
Map<String, Object> items = (Map<String, Object>) itemsRaw;
itemSchema = items.isEmpty() ? new NullSchema() : parse(items);
}
return new ArraySchema(
types,
itemSchema,
Expand All @@ -189,9 +209,9 @@

@SuppressWarnings("unchecked")
private static List<Schema> parseList(Map<String, Object> raw, String key) {
List<Map<String, Object>> raws = (List<Map<String, Object>>) raw.get(key);
List<Object> raws = (List<Object>) raw.get(key);
List<Schema> out = new ArrayList<>(raws.size());
for (Map<String, Object> r : raws) {
for (Object r : raws) {
out.add(parse(r));
}
return List.copyOf(out);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import com.retailsvc.http.ValidationException;
import com.retailsvc.http.spec.schema.AdditionalProperties;
import com.retailsvc.http.spec.schema.AllOfSchema;
import com.retailsvc.http.spec.schema.AlwaysSchema;
import com.retailsvc.http.spec.schema.AnyOfSchema;
import com.retailsvc.http.spec.schema.ArraySchema;
import com.retailsvc.http.spec.schema.BooleanSchema;
import com.retailsvc.http.spec.schema.ConstSchema;
import com.retailsvc.http.spec.schema.EnumSchema;
import com.retailsvc.http.spec.schema.IntegerSchema;
import com.retailsvc.http.spec.schema.NeverSchema;
import com.retailsvc.http.spec.schema.NotSchema;
import com.retailsvc.http.spec.schema.NullSchema;
import com.retailsvc.http.spec.schema.NumberSchema;
Expand Down Expand Up @@ -72,6 +74,10 @@ case AllOfSchema(List<Schema> parts) -> {
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);
case AlwaysSchema _ -> {
/* accepts any value, including null */
}
case NeverSchema _ -> fail(pointer, "false", "schema rejects all values", value);
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/test/java/com/retailsvc/http/OpenApiServerIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -598,4 +598,51 @@ void postBlockedForbiddenTokenReturns400() {
}
}
}

@Nested
class Gates {

String path = "/gates";

@Test
void postGateBodyWithOnlyOpenReturns200() {
try (var server = newServer(Map.of("post-gate", new EchoHandler()));
var client = httpClient()) {
var body = "{\"open\":\"anything\"}";
var request = newRequest(server, path, "POST", ofString(body));

var response = client.send(request, BodyHandlers.ofString());

assertThat(response.statusCode()).isEqualTo(200);
} catch (IOException e) {
fail(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail(e);
}
}

@Test
void postGateBodyWithBlockedReturns400() {
try (var server = newServer(Map.of("post-gate", new EchoHandler()));
var client = httpClient()) {
// Any value in 'blocked' triggers the false-schema rejection,
// because NeverSchema rejects every value.
var body = "{\"open\":\"x\",\"blocked\":\"anything\"}";
var request = newRequest(server, path, "POST", ofString(body));

var response = client.send(request, BodyHandlers.ofString());

assertThat(response.statusCode()).isEqualTo(400);
assertThat(response.headers().firstValue("Content-Type").orElse(""))
.contains("application/problem+json");
assertThat(response.body()).contains("\"keyword\":\"false\"");
} catch (IOException e) {
fail(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail(e);
}
}
}
}
50 changes: 50 additions & 0 deletions src/test/java/com/retailsvc/http/spec/schema/SchemaParserTest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.retailsvc.http.spec.schema;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -391,4 +392,53 @@ void oneOfContainingNestedAnyOfRecurses() {
assertThat(((AnyOfSchema) one.options().get(0)).options()).hasSize(2);
assertThat(one.options().get(1)).isInstanceOf(BooleanSchema.class);
}

@Test
void parsesTrueAsAlwaysSchema() {
assertThat(SchemaParser.parse(Boolean.TRUE)).isInstanceOf(AlwaysSchema.class);
}

@Test
void parsesFalseAsNeverSchema() {
assertThat(SchemaParser.parse(Boolean.FALSE)).isInstanceOf(NeverSchema.class);
}

@Test
void rejectsNonMapNonBooleanRawSchema() {
assertThatThrownBy(() -> SchemaParser.parse("oops"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("schema must be a boolean or an object");
}

@Test
void parsesObjectWithBooleanPropertySchemas() {
Schema s =
SchemaParser.parse(
Map.of("type", "object", "properties", Map.of("x", Boolean.TRUE, "y", Boolean.FALSE)));
assertThat(s).isInstanceOf(ObjectSchema.class);
ObjectSchema obj = (ObjectSchema) s;
assertThat(obj.properties().get("x")).isInstanceOf(AlwaysSchema.class);
assertThat(obj.properties().get("y")).isInstanceOf(NeverSchema.class);
}

@Test
void parsesArrayWithBooleanItemsSchema() {
Schema s = SchemaParser.parse(Map.of("type", "array", "items", Boolean.TRUE));
assertThat(s).isInstanceOf(ArraySchema.class);
assertThat(((ArraySchema) s).items()).isInstanceOf(AlwaysSchema.class);
}

@Test
void parsesArrayWithBooleanFalseItems() {
Schema s = SchemaParser.parse(Map.of("type", "array", "items", Boolean.FALSE));
assertThat(s).isInstanceOf(ArraySchema.class);
assertThat(((ArraySchema) s).items()).isInstanceOf(NeverSchema.class);
}

@Test
void rejectsNullRawSchema() {
assertThatThrownBy(() -> SchemaParser.parse(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("schema must be a boolean or an object");
}
}
Loading
Loading