diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b33b406..863ae8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: env: # The conformance suite release this repo is vendored from and must report against. - BQ_CONFORMANCE_VERSION: "1.9.0" + BQ_CONFORMANCE_VERSION: "1.10.0" # Integrity pin for fetch.sh (sha256 of conformance/fetch.sh at the pinned tag). BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dc15e3..6d53b8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ permissions: contents: write env: - BQ_CONFORMANCE_VERSION: "1.9.0" + BQ_CONFORMANCE_VERSION: "1.10.0" jobs: # Secret-free: runs the tests, produces results.json and validates it. The PyPI package it diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc6856..3fa74d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.16.0] - 2026-11-11 + +Brings the opt-in payload validator (ADR-0024, `com.babelqueue.schema`) to parity with the other SDKs, as locked by the +`payload_schema_keywords` section of conformance 1.10.0. The envelope stays at `schema_version: 1`; nothing changes on +the wire, and the validator remains zero-dependency (GR-7). + +### Added +- Union `type`: `"type": ["string", "null"]` is valid when the value matches ANY listed type, and the keywords of the + matching type (`minLength`, `minimum`, `properties`, ...) still apply. `integer` accepts every whole-valued number + (`1.0`, `1e2`, `BigDecimal("1.00")`). +- `maxLength` (counted in Unicode code points, like `minLength`). +- Registration-time warnings for keywords outside the supported subset: `PayloadValidator.warnings(schema)` returns one + `SchemaWarning(pointer, keyword)` per occurrence (RFC 6901 pointer of the schema object carrying the keyword), and + `MapProvider` reports them when the schemas are registered, each as a `RegisteredSchemaWarning(urn, warning)` so the + operator can tell which URN's schema carries it, through a `Consumer` hook + (`new MapProvider(schemas, onWarning)`; `System.Logger` at `WARNING` by default) and keeps them in + `MapProvider.warnings()`. Annotation keywords (`$schema`, `$id`, `title`, `description`, `examples`, `default`, + `$comment`, `x-*`) never warn. + +### Fixed +- `const` / `enum` compare numbers by mathematical value, recursively inside arrays and objects: `1`, `1.0` and `1e0` are + equal and `2.5` equals `2.50` whatever the `Number` subtype (`Long`, `Integer`, `Double`, `BigInteger`, `BigDecimal`, ...). + A boolean or a string is still never equal to a number. Previously a `Long` const never matched a `Double` value. +- A fractional `maxLength` is truncated toward zero (`2.9` is `2`, `-0.5` is `0`) before the comparison, like `minLength` here and the + Go, PHP and Python validators; it was compared as a raw double, which only differed for a negative fractional bound. + A non-finite `minLength` / `maxLength` (`NaN`, `Β±Infinity`; only reachable from a hand-built schema) is ignored, and a + `Long` bound above the `int` range no longer wraps (`minLength` used `intValue()`). + +### Changed +- Vendored conformance suite bumped to 1.10.0 (new `payload_schema_keywords` section, run by `PayloadSchemaConformanceTest` + and recorded in `results.json`). + ## [1.15.0] - 2026-11-01 Implements ADR-0038 (OpenTelemetry semantic-conventions alignment) and ADR-0039 (standard metrics and the diff --git a/pom.xml b/pom.xml index 5e0c8e1..119ac81 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.babelqueue babelqueue-core - 1.15.0 + 1.16.0 jar BabelQueue Core diff --git a/src/main/java/com/babelqueue/schema/MapProvider.java b/src/main/java/com/babelqueue/schema/MapProvider.java index efe1248..c4dcfc9 100644 --- a/src/main/java/com/babelqueue/schema/MapProvider.java +++ b/src/main/java/com/babelqueue/schema/MapProvider.java @@ -1,21 +1,70 @@ package com.babelqueue.schema; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; /** * In-memory {@link SchemaProvider}, for tests and for embedding schemas in code. Construct it * with each URN's already-decoded JSON Schema (a {@code Map}). + * + *

Construction is where a schema is first processed: every keyword outside the supported + * subset (see {@link PayloadValidator#warnings}) produces one {@link RegisteredSchemaWarning} + * (the {@link SchemaWarning} plus the URN it was found under), handed to the hook + * ({@link System.Logger} at {@code WARNING} by default, like the envelope codec) and + * kept in {@link #warnings()}. Warnings never change a validation verdict. */ public final class MapProvider implements SchemaProvider { + private static final System.Logger LOGGER = System.getLogger(MapProvider.class.getName()); + private final Map> schemas; + private final List warnings; /** + * Warnings go to {@link System.Logger} at {@code WARNING}. + * * @param schemas urn -> decoded JSON Schema */ public MapProvider(Map> schemas) { + this(schemas, warning -> LOGGER.log(System.Logger.Level.WARNING, warning.message())); + } + + /** + * @param schemas urn -> decoded JSON Schema + * @param onWarning receives each registration warning (with its URN) instead of the logger + */ + public MapProvider( + Map> schemas, Consumer onWarning) { + Objects.requireNonNull(onWarning, "onWarning"); this.schemas = new HashMap<>(schemas); + List collected = new ArrayList<>(); + // Iterate the supplied map, not the copy: the order is that of the caller's map. + for (Map.Entry> entry : schemas.entrySet()) { + if (entry.getValue() == null) { + continue; + } + for (SchemaWarning warning : PayloadValidator.warnings(entry.getValue())) { + RegisteredSchemaWarning registered = new RegisteredSchemaWarning(entry.getKey(), warning); + collected.add(registered); + onWarning.accept(registered); + } + } + this.warnings = Collections.unmodifiableList(collected); + } + + /** + * The warnings produced when the schemas were registered, in the iteration order of the + * map passed to the constructor (document order within one schema). + * + * @return an unmodifiable list, empty when every keyword is supported + */ + public List warnings() { + return warnings; } @Override diff --git a/src/main/java/com/babelqueue/schema/PayloadValidator.java b/src/main/java/com/babelqueue/schema/PayloadValidator.java index 89ecfc4..08a8595 100644 --- a/src/main/java/com/babelqueue/schema/PayloadValidator.java +++ b/src/main/java/com/babelqueue/schema/PayloadValidator.java @@ -1,24 +1,85 @@ package com.babelqueue.schema; +import java.math.BigDecimal; import java.math.BigInteger; +import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Set; /** * Validates a message's {@code data} block against a per-URN JSON Schema (ADR-0024). A * hand-rolled subset of Draft-07 (zero dependencies, GR-7) whose verdicts match the Go, PHP, * Python and Node validators and babelqueue-registry's {@code compat} linter. Supported - * keywords: {@code type}, {@code required}, {@code properties}, {@code additionalProperties}, - * {@code items}, {@code enum}, {@code const}, {@code minLength}, {@code minimum}; unknown - * keywords are ignored. It works on the decoded {@code Map}/{@code List}/scalar structures - * the codec produces (numbers are {@link Long}/{@link BigInteger}/{@link Double}). + * keywords: {@code type} (a name or an array of names, i.e. a union), {@code required}, + * {@code properties}, {@code additionalProperties}, {@code items}, {@code enum}, {@code const}, + * {@code minLength}, {@code maxLength}, {@code minimum}. A keyword outside the subset never + * changes the verdict (it is evaluated as absent), but {@link #warnings(Map)} reports each + * occurrence so registration can warn about it. {@code const}/{@code enum} compare numbers by + * mathematical value ({@code 1 == 1.0 == 1e0}), recursively, and never equate a boolean or a + * string with a number. It works on the decoded {@code Map}/{@code List}/scalar structures + * the codec produces (numbers are {@link Long}/{@link BigInteger}/{@link Double}) and + * tolerates any {@link Number} subtype ({@link Integer}, {@link BigDecimal}, ...). */ public final class PayloadValidator { + // Annotation keywords never warn; so does every keyword starting with "x-" (e.g. the + // ADR-0030 x-gdpr-sensitive). + private static final Set ANNOTATIONS = + Set.of("$schema", "$id", "title", "description", "examples", "default", "$comment"); + + private static final Set SUPPORTED = Set.of( + "type", "required", "properties", "additionalProperties", "items", "enum", "const", + "minLength", "maxLength", "minimum"); + private PayloadValidator() { } + /** + * Every keyword in {@code schema} that is outside the supported subset, one + * {@link SchemaWarning} per occurrence. The walk descends only into the values of + * {@code properties}, {@code items} and {@code additionalProperties} when each is an + * object, and never into the value of an unsupported keyword. Annotation keywords + * ({@code $schema}, {@code $id}, {@code title}, {@code description}, {@code examples}, + * {@code default}, {@code $comment}, {@code x-*}) are not unsupported. The verdict of + * {@link #validate} is unaffected. + * + * @param schema the decoded JSON Schema for the data block + * @return the warnings in document order, empty when every keyword is supported + */ + public static List warnings(Map schema) { + List out = new ArrayList<>(); + collectWarnings(schema, "", out); + return out; + } + + private static void collectWarnings(Map schema, String pointer, List out) { + for (Object key : schema.keySet()) { + String keyword = String.valueOf(key); + if (!SUPPORTED.contains(keyword) && !ANNOTATIONS.contains(keyword) && !keyword.startsWith("x-")) { + out.add(new SchemaWarning(pointer, keyword)); + } + } + if (schema.get("properties") instanceof Map properties) { + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() instanceof Map child) { + collectWarnings(child, pointer + "/properties/" + escapePointer(String.valueOf(entry.getKey())), out); + } + } + } + if (schema.get("items") instanceof Map items) { + collectWarnings(items, pointer + "/items", out); + } + if (schema.get("additionalProperties") instanceof Map additional) { + collectWarnings(additional, pointer + "/additionalProperties", out); + } + } + + // RFC 6901: "~" -> "~0" first, then "/" -> "~1". + private static String escapePointer(String token) { + return token.replace("~", "~0").replace("/", "~1"); + } + /** * The first violation of {@code value} against {@code schema} as * {@code ": "}, or {@code null} when it conforms. @@ -32,14 +93,62 @@ public static String validate(Map schema, Object value) { } private static String validateNode(Map schema, Object value, String path) { - if (schema.containsKey("const") && !Objects.equals(value, schema.get("const"))) { + if (schema.containsKey("const") && !jsonEquals(value, schema.get("const"))) { return violation(path, "wrong_const"); } if (schema.get("enum") instanceof List enumValues && !contains(enumValues, value)) { return violation(path, "not_in_enum"); } - String type = (schema.get("type") instanceof String s) ? s : ""; + Object type = schema.get("type"); + if (type instanceof List union) { + return checkUnion(schema, union, value, path); + } + return checkType(schema, (type instanceof String s) ? s : "", value, path); + } + + // A union is valid when the value matches ANY listed type; the keywords of the matching + // type(s) then apply to it (an integer/number pair runs the same minimum check twice at + // worst). An empty union and an unknown type name constrain nothing, like an unknown + // single `type`. + private static String checkUnion(Map schema, List union, Object value, String path) { + boolean sawType = false; + String shapeViolation = null; + for (Object member : union) { + if (!(member instanceof String name)) { + continue; + } + sawType = true; + String found = checkType(schema, name, value, path); + if (found == null) { + return null; + } + // A type that matches the value's shape but fails one of its keywords (minLength, + // minimum, nested properties...) explains the failure better than a bare mismatch. + if (shapeViolation == null && matchesType(name, value)) { + shapeViolation = found; + } + } + if (!sawType) { + return null; + } + return shapeViolation != null ? shapeViolation : violation(path, "not_in_type_union"); + } + + private static boolean matchesType(String type, Object value) { + return switch (type) { + case "object" -> value instanceof Map; + case "array" -> value instanceof List; + case "string" -> value instanceof String; + case "integer" -> isInteger(value); + case "number" -> value instanceof Number; + case "boolean" -> value instanceof Boolean; + case "null" -> value == null; + default -> true; + }; + } + + private static String checkType(Map schema, String type, Object value, String path) { return switch (type) { case "object" -> checkObject(schema, value, path); case "array" -> checkArray(schema, value, path); @@ -100,15 +209,33 @@ private static String checkArray(Map schema, Object value, String path) { return null; } + /** + * A {@code minLength}/{@code maxLength} bound truncated toward zero (2.9 -> 2, -0.5 -> 0), like Go, + * PHP and Python. {@code (long)} of a double truncates and saturates instead of wrapping. A missing, + * non-numeric or non-finite (NaN / +-Infinity) bound is ignored ({@code null}). + */ + private static Long lengthBound(Object bound) { + if (!(bound instanceof Number number)) { + return null; + } + double d = number.doubleValue(); + return Double.isFinite(d) ? (long) d : null; + } + private static String checkString(Map schema, Object value, String path) { if (!(value instanceof String str)) { return violation(path, "not_a_string"); } // JSON Schema counts string length in Unicode code points, not UTF-16 units. - if (schema.get("minLength") instanceof Number min - && str.codePointCount(0, str.length()) < min.intValue()) { + int length = str.codePointCount(0, str.length()); + Long min = lengthBound(schema.get("minLength")); + if (min != null && length < min) { return violation(path, "below_min_length"); } + Long max = lengthBound(schema.get("maxLength")); + if (max != null && length > max) { + return violation(path, "above_max_length"); + } return null; } @@ -122,23 +249,92 @@ private static String checkMinimum(Map schema, Object value, String path) } // JSON numbers decode to Long/BigInteger (integers) or Double (fractions); a whole-valued - // Double still counts as an integer, matching the other SDKs. Booleans are not Numbers. + // number of any representation (1.0, 1e2, BigDecimal("1.00")) still counts as an integer, + // matching the other SDKs. Booleans are not Numbers. private static boolean isInteger(Object value) { if (value instanceof Long || value instanceof Integer || value instanceof BigInteger) { return true; } - if (value instanceof Double d) { - return !d.isInfinite() && Double.compare(d, Math.floor(d)) == 0; + if (value instanceof Number n) { + BigDecimal d = toBigDecimal(n); + return d != null && (d.signum() == 0 || d.stripTrailingZeros().scale() <= 0); + } + return false; + } + + // The exact decimal value of a Number, or null for NaN/Infinity. A Double/Float goes + // through its shortest decimal string, i.e. the value the JSON literal denoted. + private static BigDecimal toBigDecimal(Number n) { + if (n instanceof BigDecimal bd) { + return bd; + } + if (n instanceof BigInteger bi) { + return new BigDecimal(bi); + } + if (n instanceof Long || n instanceof Integer || n instanceof Short || n instanceof Byte) { + return BigDecimal.valueOf(n.longValue()); + } + if (n instanceof Double || n instanceof Float) { + double d = n.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return null; + } + return new BigDecimal(n instanceof Float ? Float.toString(n.floatValue()) : Double.toString(d)); + } + try { + return new BigDecimal(n.toString()); + } catch (NumberFormatException e) { + double d = n.doubleValue(); + return (Double.isNaN(d) || Double.isInfinite(d)) ? null : new BigDecimal(Double.toString(d)); + } + } + + // JSON-Schema equality for const/enum: numbers by mathematical value (recursively inside + // arrays and objects), objects by key set (order-free), arrays element by element, and + // nothing crosses kinds (true != 1, "1" != 1, null equals only null). + private static boolean jsonEquals(Object a, Object b) { + if (a == null || b == null) { + return a == null && b == null; + } + if (a instanceof Number x && b instanceof Number y) { + BigDecimal bx = toBigDecimal(x); + BigDecimal by = toBigDecimal(y); + if (bx == null || by == null) { + return bx == null && by == null && Double.compare(x.doubleValue(), y.doubleValue()) == 0; + } + return bx.compareTo(by) == 0; + } + if (a instanceof Map ma && b instanceof Map mb) { + if (ma.size() != mb.size()) { + return false; + } + for (Map.Entry entry : ma.entrySet()) { + if (!mb.containsKey(entry.getKey()) || !jsonEquals(entry.getValue(), mb.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (a instanceof List la && b instanceof List lb) { + if (la.size() != lb.size()) { + return false; + } + for (int i = 0; i < la.size(); i++) { + if (!jsonEquals(la.get(i), lb.get(i))) { + return false; + } + } + return true; } - if (value instanceof Float f) { - return !f.isInfinite() && Float.compare(f, (float) Math.floor(f)) == 0; + if (a instanceof Boolean || a instanceof String) { + return a.equals(b); } return false; } private static boolean contains(List values, Object value) { for (Object item : values) { - if (Objects.equals(value, item)) { + if (jsonEquals(value, item)) { return true; } } diff --git a/src/main/java/com/babelqueue/schema/RegisteredSchemaWarning.java b/src/main/java/com/babelqueue/schema/RegisteredSchemaWarning.java new file mode 100644 index 0000000..2bdee5b --- /dev/null +++ b/src/main/java/com/babelqueue/schema/RegisteredSchemaWarning.java @@ -0,0 +1,23 @@ +package com.babelqueue.schema; + +/** + * A {@link SchemaWarning} together with the URN whose registered schema produced it, so an + * operator can tell which schema asks for more than the validator enforces. + * + * @param urn the message URN the schema is registered under + * @param warning the unsupported-keyword warning ({@code pointer} and {@code keyword}) + */ +public record RegisteredSchemaWarning(String urn, SchemaWarning warning) { + + /** + * A human-readable line for logs; the conformance suite compares only the warning's + * {@code pointer} and {@code keyword}. + * + * @return the message + */ + public String message() { + return "BabelQueue: schema for '" + urn + "': keyword '" + warning.keyword() + "' at '" + + (warning.pointer().isEmpty() ? "" : warning.pointer()) + + "' is not supported by the payload validator and is ignored"; + } +} diff --git a/src/main/java/com/babelqueue/schema/SchemaWarning.java b/src/main/java/com/babelqueue/schema/SchemaWarning.java new file mode 100644 index 0000000..1df25de --- /dev/null +++ b/src/main/java/com/babelqueue/schema/SchemaWarning.java @@ -0,0 +1,14 @@ +package com.babelqueue.schema; + +/** + * A registration-time warning that a schema uses a keyword outside the supported Draft-07 + * subset (ADR-0024, K-17). The keyword is ignored when validating, so the verdict is that of a + * schema without it; the warning only tells the operator the schema asks for more than this + * SDK enforces. + * + * @param pointer the RFC 6901 JSON Pointer, within the schema document, of the schema object + * that carries the keyword ({@code ""} is the root) + * @param keyword the unsupported keyword + */ +public record SchemaWarning(String pointer, String keyword) { +} diff --git a/src/test/java/com/babelqueue/PayloadSchemaConformanceTest.java b/src/test/java/com/babelqueue/PayloadSchemaConformanceTest.java index bcb2bd9..1ee7080 100644 --- a/src/test/java/com/babelqueue/PayloadSchemaConformanceTest.java +++ b/src/test/java/com/babelqueue/PayloadSchemaConformanceTest.java @@ -5,14 +5,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.DynamicTest.dynamicTest; +import com.babelqueue.schema.MapProvider; import com.babelqueue.schema.PayloadValidator; +import com.babelqueue.schema.RegisteredSchemaWarning; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; @@ -20,7 +24,9 @@ /** * Runs the shared cross-SDK payload-schema cases (ADR-0024) from the vendored conformance * suite: this validator must agree with the Go, PHP, Python and Node ones on each case's - * {@code valid} flag. + * {@code valid} flag. The {@code payload_schema_keywords} section (union types, numeric + * equality, unsupported-keyword warnings) also compares the registration warnings as a set of + * {@code (pointer, keyword)} pairs. */ class PayloadSchemaConformanceTest { @@ -52,6 +58,52 @@ Stream payloadCasesMatchAcrossSdks() { return tests.stream(); } + @TestFactory + @SuppressWarnings("unchecked") + Stream keywordCasesMatchAcrossSdks() { + Map manifest = + (Map) Json.parse(readResource("/conformance/manifest.json")); + Object sectionObj = manifest.get("payload_schema_keywords"); + assertNotNull(sectionObj, "manifest has a payload_schema_keywords section"); + + List cases = (List) ((Map) sectionObj).get("cases"); + assertFalse(cases.isEmpty()); + + List tests = new ArrayList<>(); + for (int i = 0; i < cases.size(); i++) { + Map testCase = (Map) cases.get(i); + Object name = testCase.get("name"); + String id = name instanceof String s ? s : "payload_schema_keywords#" + i; + tests.add(dynamicTest(id, ConformanceResults.record("payload_schema_keywords", id, () -> { + Map schema = (Map) testCase.get("schema"); + Map data = (Map) testCase.get("data"); + boolean expected = Boolean.TRUE.equals(testCase.get("valid")); + + // Registration produces the warnings; the verdict is unaffected by them. + List produced = new ArrayList<>(); + new MapProvider(Map.of("urn:test:case", schema), produced::add); + boolean isValid = PayloadValidator.validate(schema, data) == null; + assertEquals(expected, isValid, "case " + id); + + Set expectedWarnings = new HashSet<>(); + Object listed = testCase.get("warnings"); + if (listed instanceof List items) { + for (Object item : items) { + Map w = (Map) item; + expectedWarnings.add(w.get("pointer") + "\u0000" + w.get("keyword")); + } + } + Set actualWarnings = new HashSet<>(); + for (RegisteredSchemaWarning w : produced) { + actualWarnings.add(w.warning().pointer() + "\u0000" + w.warning().keyword()); + } + assertEquals(expectedWarnings, actualWarnings, "warnings of " + id); + assertEquals(actualWarnings.size(), produced.size(), "one warning per occurrence in " + id); + }))); + } + return tests.stream(); + } + private static String readResource(String path) { try (InputStream in = PayloadSchemaConformanceTest.class.getResourceAsStream(path)) { assertNotNull(in, "missing resource " + path); diff --git a/src/test/java/com/babelqueue/SchemaValidationTest.java b/src/test/java/com/babelqueue/SchemaValidationTest.java index 88c5e02..962c2c7 100644 --- a/src/test/java/com/babelqueue/SchemaValidationTest.java +++ b/src/test/java/com/babelqueue/SchemaValidationTest.java @@ -1,16 +1,24 @@ package com.babelqueue; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.babelqueue.idempotency.Handler; import com.babelqueue.schema.InvalidPayloadException; import com.babelqueue.schema.MapProvider; import com.babelqueue.schema.PayloadValidator; +import com.babelqueue.schema.RegisteredSchemaWarning; import com.babelqueue.schema.SchemaProvider; import com.babelqueue.schema.SchemaValidation; +import com.babelqueue.schema.SchemaWarning; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -102,4 +110,201 @@ void wrapRunsOnValidThrowsOnInvalidRunsForUnregistered() throws Exception { handler.handle(EnvelopeCodec.make("urn:babel:unknown", json("{\"anything\":true}"))); assertEquals(2, calls.get()); } + + @Test + void unionTypeAcceptsAnyListedTypeAndRejectsTheRest() { + Map s = json("{\"type\":[\"string\",\"null\"]}"); + assertNull(PayloadValidator.validate(s, "x")); + assertNull(PayloadValidator.validate(s, null)); + assertNotNull(PayloadValidator.validate(s, 5L)); + assertNotNull(PayloadValidator.validate(s, Boolean.FALSE)); + assertEquals(": not_in_type_union", PayloadValidator.validate(s, 5L)); + } + + @Test + void unionIntegerAcceptsWholeNumbersInAnyNumberType() { + Map s = json("{\"type\":[\"integer\",\"null\"]}"); + assertNull(PayloadValidator.validate(s, 1.0)); + assertNull(PayloadValidator.validate(s, 100.0)); + assertNull(PayloadValidator.validate(s, 1.0f)); + assertNull(PayloadValidator.validate(s, new BigDecimal("1.00"))); + assertNull(PayloadValidator.validate(s, new BigDecimal("1E+2"))); + assertNull(PayloadValidator.validate(s, BigInteger.TEN.pow(30))); + assertNull(PayloadValidator.validate(s, Integer.valueOf(3))); + assertNotNull(PayloadValidator.validate(s, 1.5)); + assertNotNull(PayloadValidator.validate(s, new BigDecimal("1.5"))); + assertNotNull(PayloadValidator.validate(s, "1")); + } + + @Test + void unionKeepsTheKeywordsOfTheMatchingTypeEnforced() { + Map s = json("{\"type\":[\"integer\",\"null\"],\"minimum\":0}"); + assertNull(PayloadValidator.validate(s, 3L)); + assertNull(PayloadValidator.validate(s, null)); + assertEquals(": below_minimum", PayloadValidator.validate(s, -1L)); + + Map obj = json( + "{\"type\":[\"object\",\"null\"],\"required\":[\"id\"]," + + "\"properties\":{\"id\":{\"type\":\"integer\"}}}"); + assertNull(PayloadValidator.validate(obj, json("{\"id\":1}"))); + assertNull(PayloadValidator.validate(obj, null)); + assertEquals("id: missing_required", PayloadValidator.validate(obj, json("{}"))); + assertEquals("id: not_an_integer", PayloadValidator.validate(obj, json("{\"id\":\"x\"}"))); + + Map str = json("{\"type\":[\"string\",\"null\"],\"minLength\":2,\"maxLength\":3}"); + assertEquals(": below_min_length", PayloadValidator.validate(str, "a")); + assertEquals(": above_max_length", PayloadValidator.validate(str, "abcd")); + assertNull(PayloadValidator.validate(str, "abc")); + } + + @Test + void fractionalLengthBoundsAreTruncatedTowardZero() { + Map min = json("{\"type\":\"string\",\"minLength\":2.5}"); + assertNull(PayloadValidator.validate(min, "ab")); + assertNotNull(PayloadValidator.validate(min, "a")); + + Map max = json("{\"type\":\"string\",\"maxLength\":2.9}"); + assertNull(PayloadValidator.validate(max, "ab")); + assertNotNull(PayloadValidator.validate(max, "abc")); + + // A negative fractional bound truncates to 0: only the empty string fits. + Map negMax = json("{\"type\":\"string\",\"maxLength\":-0.5}"); + assertNull(PayloadValidator.validate(negMax, "")); + assertNotNull(PayloadValidator.validate(negMax, "a")); + Map negMin = json("{\"type\":\"string\",\"minLength\":-0.5}"); + assertNull(PayloadValidator.validate(negMin, "")); + } + + @Test + void nonFiniteAndOversizedLengthBoundsAreHandledLikeTheOtherSdks() { + for (String keyword : new String[] {"minLength", "maxLength"}) { + for (double bound : new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + Map schema = Map.of("type", "string", keyword, bound); + for (String text : new String[] {"", "a", "abc"}) { + assertNull(PayloadValidator.validate(schema, text), keyword + "=" + bound + " must be ignored"); + } + } + } + // A Long bound above the int range used to wrap through intValue(): 4294967298 is not 2. + assertNotNull(PayloadValidator.validate(Map.of("type", "string", "minLength", 4294967298L), "ab")); + assertNull(PayloadValidator.validate(Map.of("type", "string", "maxLength", 4294967298L), "abc")); + } + + @Test + void maxLengthCountsCodePoints() { + Map s = json("{\"type\":\"string\",\"maxLength\":3}"); + assertNull(PayloadValidator.validate(s, "\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00")); + assertNotNull(PayloadValidator.validate(s, "\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00")); + assertNotNull(PayloadValidator.validate(json("{\"type\":\"string\",\"maxLength\":1}"), "e\u0301")); + } + + @Test + void constAndEnumCompareNumbersByValueAcrossNumberTypes() { + Map one = json("{\"const\":1}"); + assertNull(PayloadValidator.validate(one, 1L)); + assertNull(PayloadValidator.validate(one, 1.0)); + assertNull(PayloadValidator.validate(one, 1.0f)); + assertNull(PayloadValidator.validate(one, Integer.valueOf(1))); + assertNull(PayloadValidator.validate(one, new BigDecimal("1.000"))); + assertNull(PayloadValidator.validate(one, BigInteger.ONE)); + assertNotNull(PayloadValidator.validate(one, 1.5)); + assertNotNull(PayloadValidator.validate(one, Boolean.TRUE)); + assertNotNull(PayloadValidator.validate(one, "1")); + assertNotNull(PayloadValidator.validate(one, null)); + + // The schema side may carry any number type too. + Map schemaWithDecimal = Map.of("const", new BigDecimal("2.50")); + assertNull(PayloadValidator.validate(schemaWithDecimal, 2.5)); + assertNull(PayloadValidator.validate(Map.of("const", 1L), 1.0)); + assertNull(PayloadValidator.validate(Map.of("enum", List.of(1L, 2.5)), new BigDecimal("2.50"))); + + assertNotNull(PayloadValidator.validate(json("{\"const\":true}"), 1L)); + assertNotNull(PayloadValidator.validate(json("{\"const\":false}"), 0L)); + assertNull(PayloadValidator.validate(json("{\"const\":null}"), null)); + assertNotNull(PayloadValidator.validate(json("{\"const\":null}"), 0L)); + assertNull(PayloadValidator.validate(json("{\"enum\":[true,null,0]}"), 0.0)); + assertNotNull(PayloadValidator.validate(json("{\"enum\":[true,null,0]}"), 1L)); + } + + @Test + void constAndEnumCompareNestedStructuresByValue() { + Map c = json("{\"const\":{\"a\":1,\"b\":[2,{\"c\":3}]}}"); + assertNull(PayloadValidator.validate(c, json("{\"b\":[2.0,{\"c\":3e0}],\"a\":1.0}"))); + assertNotNull(PayloadValidator.validate(c, json("{\"a\":1,\"b\":[2,{\"c\":true}]}"))); + assertNotNull(PayloadValidator.validate(c, json("{\"a\":1,\"b\":[{\"c\":3},2]}"))); + assertNotNull(PayloadValidator.validate(c, json("{\"a\":1,\"b\":[2,{\"c\":3}],\"d\":1}"))); + Map e = json("{\"enum\":[{\"n\":1},{\"n\":2}]}"); + assertNull(PayloadValidator.validate(e, json("{\"n\":1.0}"))); + assertNotNull(PayloadValidator.validate(e, json("{\"n\":\"1\"}"))); + } + + @Test + void warningsListEachUnsupportedKeywordWithItsPointer() { + Map schema = json( + "{\"type\":\"object\",\"minProperties\":2," + + "\"properties\":{\"a/b\":{\"type\":\"string\",\"pattern\":\"x\"}," + + "\"m~n\":{\"type\":\"array\",\"items\":{\"type\":\"integer\",\"multipleOf\":2}}}," + + "\"additionalProperties\":{\"type\":\"string\",\"format\":\"x\"}," + + "\"allOf\":[{\"type\":\"integer\",\"pattern\":\"x\"}]}"); + + List warnings = PayloadValidator.warnings(schema); + + assertEquals(5, warnings.size()); + assertTrue(warnings.contains(new SchemaWarning("", "minProperties"))); + assertTrue(warnings.contains(new SchemaWarning("", "allOf"))); + assertTrue(warnings.contains(new SchemaWarning("/properties/a~1b", "pattern"))); + assertTrue(warnings.contains(new SchemaWarning("/properties/m~0n/items", "multipleOf"))); + assertTrue(warnings.contains(new SchemaWarning("/additionalProperties", "format"))); + // The unsupported keyword never changes the verdict. + assertNull(PayloadValidator.validate(json("{\"type\":\"object\",\"minProperties\":2}"), json("{\"a\":1}"))); + } + + @Test + void annotationKeywordsAndPropertyNamesNeverWarn() { + Map schema = json( + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"$id\":\"urn:x\",\"title\":\"T\"," + + "\"description\":\"d\",\"$comment\":\"c\",\"examples\":[{}],\"default\":{},\"x-owner\":\"billing\"," + + "\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\",\"x-gdpr-sensitive\":true}," + + "\"pattern\":{\"type\":\"string\"},\"x-gdpr-sensitive\":{\"type\":\"string\"}}}"); + assertTrue(PayloadValidator.warnings(schema).isEmpty()); + } + + @Test + void mapProviderWarnsOnRegistrationThroughTheHookAndKeepsTheList() { + Map schema = json("{\"type\":\"string\",\"pattern\":\"^x\"}"); + List seen = new ArrayList<>(); + + MapProvider p = new MapProvider(Map.of("urn:babel:a", schema), seen::add); + + assertEquals(List.of(new RegisteredSchemaWarning("urn:babel:a", new SchemaWarning("", "pattern"))), seen); + assertEquals(seen, p.warnings()); + assertTrue(seen.get(0).message().contains("urn:babel:a")); + // Registration warns; validation ignores the keyword. + assertEquals(": not_a_string", SchemaValidation.check(p, "urn:babel:a", Map.of())); + } + + @Test + void sameKeywordInTwoSchemasIsAttributedToEachUrn() { + Map> schemas = new LinkedHashMap<>(); + schemas.put("urn:babel:a", json("{\"type\":\"object\",\"properties\":{\"email\":{\"pattern\":\"x\"}}}")); + schemas.put("urn:babel:b", json("{\"type\":\"object\",\"properties\":{\"email\":{\"pattern\":\"x\"}}}")); + + MapProvider p = new MapProvider(schemas, w -> { }); + + assertEquals(List.of( + new RegisteredSchemaWarning("urn:babel:a", new SchemaWarning("/properties/email", "pattern")), + new RegisteredSchemaWarning("urn:babel:b", new SchemaWarning("/properties/email", "pattern"))), + p.warnings()); + } + + @Test + void mapProviderSkipsNullSchemaValues() { + Map> schemas = new java.util.HashMap<>(); + schemas.put("urn:babel:none", null); + + MapProvider p = new MapProvider(schemas, w -> { }); + + assertTrue(p.warnings().isEmpty()); + assertNull(p.schemaFor("urn:babel:none")); + } } diff --git a/src/test/resources/conformance/CONFORMANCE_VERSION b/src/test/resources/conformance/CONFORMANCE_VERSION index f8e233b..81c871d 100644 --- a/src/test/resources/conformance/CONFORMANCE_VERSION +++ b/src/test/resources/conformance/CONFORMANCE_VERSION @@ -1 +1 @@ -1.9.0 +1.10.0 diff --git a/src/test/resources/conformance/manifest.json b/src/test/resources/conformance/manifest.json index 70a44fc..637f8dd 100644 --- a/src/test/resources/conformance/manifest.json +++ b/src/test/resources/conformance/manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "conformance_version": "1.9.0", + "conformance_version": "1.10.0", "sections": { "cases": { "scope": "core" @@ -50,6 +50,9 @@ "payload_schema_unicode": { "scope": "core" }, + "payload_schema_keywords": { + "scope": "core" + }, "trim": { "scope": "core" }, @@ -1075,6 +1078,244 @@ } ] }, + "payload_schema_keywords": { + "description": "Draft-07 subset keyword semantics (ADR-0024, K-17). The subset is exactly `type`, `required`, `properties`, `additionalProperties`, `items`, `enum`, `const`, `minLength`, `maxLength`, `minimum`; every SDK's optional payload validator MUST agree on both the verdict and the registration warnings below (GR-5). The wire envelope stays frozen: this governs the opt-in `data` validation only. Shape: `cases` is a list of `{ name, schema, data, valid, warnings? }`; unlike `payload_schema` each case carries its own `schema`. A runner registers `schema`, collects the warnings the registration produces, then validates `data` and compares `valid`. Three rules are pinned. (1) UNION TYPES: `type` may be an array of type names (`[\"string\", \"null\"]`); a value is valid when it matches ANY listed type. `integer` accepts every number whose mathematical value is integral, so `1.0` and `1e0` and `1e2` are integers and `1.5` is not (Draft-07). `number` accepts any number, `boolean` accepts only `true`/`false` (never `0`/`1`), `null` accepts only `null`. (2) NUMERIC EQUALITY in `const` and `enum`: numbers compare by mathematical value, so `1`, `1.0` and `1e0` are equal and `2.5` equals `2.50`; the rule applies recursively inside arrays and objects (objects compare by key set and values, key order is irrelevant; arrays compare element by element in order). `true` is not `1`, `false` is not `0`, `\"1\"` is not `1`, and `null` equals only `null`. Write the JSON number literal as the case does (`1.0`, `1e0`, `2.50`): a runner MUST NOT reject or normalise it before comparing. (3) UNSUPPORTED KEYWORDS: a keyword outside the subset does NOT change the verdict (the schema is evaluated as if the keyword were absent) but registering the schema produces exactly one warning per unsupported keyword occurrence, `{ pointer, keyword }`. `pointer` is the RFC 6901 JSON Pointer, within the schema document, of the schema object that carries the keyword: the root is the empty string `\"\"`, then `/properties/`, `/items`, `/additionalProperties` (property names are escaped `~` -> `~0`, `/` -> `~1`). The walk descends only into the values of `properties`, into `items` and into `additionalProperties` when each is a JSON object; it never descends into the value of an unsupported keyword (`allOf`, `definitions`, ...), so keywords inside such a value produce no warning, and the names inside `properties` are property names, never keywords. Annotation keywords are not unsupported and never warn: `$schema`, `$id`, `title`, `description`, `examples`, `default`, `$comment`, and every keyword whose name starts with `x-` (for example the ADR-0030 `x-gdpr-sensitive`). The expected warnings of a case are the `warnings` field compared as a SET of `{ pointer, keyword }` pairs (order is irrelevant, the message text is the SDK's own and is never compared); an absent `warnings` means the expected set is empty. Because rule 3 evaluates an unsupported keyword as absent, a case whose schema uses a keyword that a full Draft-07 validator enforces (`allOf`, `$ref`, `maximum`, `pattern`, ...) has the subset's verdict, not Draft-07's; for that reason the reference Draft-07 cross-check of `ci.yml` does not cover this section.", + "cases": [ + { "name": "union-string-null-accepts-string", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": "abc" }, "valid": true }, + { "name": "union-string-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": null }, "valid": true }, + { "name": "union-string-null-rejects-number", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": 5 }, "valid": false }, + { "name": "union-string-null-rejects-boolean", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": false }, "valid": false }, + { "name": "union-integer-null-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "union-integer-null-accepts-1e2", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1e2 }, "valid": true }, + { "name": "union-integer-null-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "union-integer-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": null }, "valid": true }, + { "name": "union-integer-boolean-rejects-numeric-string", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "boolean"] } } }, "data": { "v": "1" }, "valid": false }, + { "name": "type-integer-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "type": "integer" } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "type-integer-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "type": "integer" } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "union-nested-properties-accepts-null", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "email": { "type": ["string", "null"] }, "age": { "type": ["integer", "null"], "minimum": 0 } } } } }, "data": { "user": { "email": null, "age": 30.0 } }, "valid": true }, + { "name": "union-nested-properties-rejects-wrong-type", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "email": { "type": ["string", "null"] } } } } }, "data": { "user": { "email": 7 } }, "valid": false }, + { "name": "union-nested-minimum-still-applies", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "age": { "type": ["integer", "null"], "minimum": 0 } } } } }, "data": { "user": { "age": -1 } }, "valid": false }, + { "name": "union-items-accepts-member-types", "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": ["string", "number"] } } } }, "data": { "list": ["a", 2, 3.5] }, "valid": true }, + { "name": "union-items-rejects-boolean-member", "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": ["string", "number"] } } } }, "data": { "list": ["a", true] }, "valid": false }, + { "name": "const-number-accepts-1", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1 }, "valid": true }, + { "name": "const-number-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "const-number-accepts-1e0", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1e0 }, "valid": true }, + { "name": "const-number-rejects-true", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": true }, "valid": false }, + { "name": "const-number-rejects-string-1", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": "1" }, "valid": false }, + { "name": "const-number-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "const-number-rejects-null", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": null }, "valid": false }, + { "name": "const-1.0-accepts-integer-1", "schema": { "type": "object", "properties": { "v": { "const": 1.0 } } }, "data": { "v": 1 }, "valid": true }, + { "name": "const-true-rejects-1", "schema": { "type": "object", "properties": { "v": { "const": true } } }, "data": { "v": 1 }, "valid": false }, + { "name": "const-false-rejects-0", "schema": { "type": "object", "properties": { "v": { "const": false } } }, "data": { "v": 0 }, "valid": false }, + { "name": "const-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": null }, "valid": true }, + { "name": "const-null-rejects-zero", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": 0 }, "valid": false }, + { "name": "const-null-rejects-empty-string", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": "" }, "valid": false }, + { "name": "enum-numbers-accepts-2.50", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 2.50 }, "valid": true }, + { "name": "enum-numbers-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "enum-numbers-accepts-exponent-form", "schema": { "type": "object", "properties": { "v": { "enum": [10, 2.5] } } }, "data": { "v": 1e1 }, "valid": true }, + { "name": "enum-numbers-rejects-3", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 3 }, "valid": false }, + { "name": "enum-numbers-rejects-string-1", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": "1" }, "valid": false }, + { "name": "enum-numbers-rejects-true", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": true }, "valid": false }, + { "name": "enum-mixed-keeps-booleans-apart", "schema": { "type": "object", "properties": { "v": { "enum": [true, null, 0] } } }, "data": { "v": 1 }, "valid": false }, + { "name": "enum-mixed-accepts-0.0-for-listed-0", "schema": { "type": "object", "properties": { "v": { "enum": [true, null, 0] } } }, "data": { "v": 0.0 }, "valid": true }, + { "name": "const-object-accepts-1.0-in-nested-array", "schema": { "const": { "a": [1] } }, "data": { "a": [1.0] }, "valid": true }, + { "name": "const-object-rejects-true-in-nested-array", "schema": { "const": { "a": [1] } }, "data": { "a": [true] }, "valid": false }, + { "name": "const-object-rejects-longer-array", "schema": { "const": { "a": [1] } }, "data": { "a": [1, 2] }, "valid": false }, + { "name": "const-object-rejects-extra-key", "schema": { "const": { "a": [1] } }, "data": { "a": [1], "b": 1 }, "valid": false }, + { "name": "const-object-ignores-key-order", "schema": { "const": { "a": 1, "b": [2, { "c": 3 }] } }, "data": { "b": [2.0, { "c": 3e0 }], "a": 1.0 }, "valid": true }, + { "name": "const-object-keeps-array-order", "schema": { "const": { "a": [1, 2] } }, "data": { "a": [2, 1] }, "valid": false }, + { "name": "enum-object-accepts-1.0-inside-object", "schema": { "type": "object", "properties": { "v": { "enum": [{ "n": 1 }, { "n": 2 }] } } }, "data": { "v": { "n": 1.0 } }, "valid": true }, + { "name": "enum-object-rejects-string-inside-object", "schema": { "type": "object", "properties": { "v": { "enum": [{ "n": 1 }, { "n": 2 }] } } }, "data": { "v": { "n": "1" } }, "valid": false }, + { + "name": "unsupported-pattern-format-maxitems-valid-unchanged", + "schema": { "type": "object", "properties": { "email": { "type": "string", "pattern": "^.+@.+$", "format": "email" }, "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 1 } } }, + "data": { "email": "not-an-email", "tags": ["a", "b"] }, + "valid": true, + "warnings": [ + { "pointer": "/properties/email", "keyword": "pattern" }, + { "pointer": "/properties/email", "keyword": "format" }, + { "pointer": "/properties/tags", "keyword": "maxItems" } + ] + }, + { + "name": "unsupported-keywords-supported-ones-still-enforced", + "schema": { "type": "object", "properties": { "email": { "type": "string", "pattern": "^.+@.+$", "format": "email" } } }, + "data": { "email": 12 }, + "valid": false, + "warnings": [ + { "pointer": "/properties/email", "keyword": "pattern" }, + { "pointer": "/properties/email", "keyword": "format" } + ] + }, + { + "name": "unsupported-root-keyword", + "schema": { "type": "object", "minProperties": 2 }, + "data": { "a": 1 }, + "valid": true, + "warnings": [{ "pointer": "", "keyword": "minProperties" }] + }, + { + "name": "unsupported-maximum-is-not-enforced", + "schema": { "type": "object", "properties": { "n": { "type": "integer", "minimum": 1, "maximum": 5 } } }, + "data": { "n": 10 }, + "valid": true, + "warnings": [{ "pointer": "/properties/n", "keyword": "maximum" }] + }, + { + "name": "unsupported-in-items-schema", + "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "integer", "multipleOf": 2 } } } }, + "data": { "list": [1, 2, 3] }, + "valid": true, + "warnings": [{ "pointer": "/properties/list/items", "keyword": "multipleOf" }] + }, + { + "name": "unsupported-in-additional-properties-schema", + "schema": { "type": "object", "additionalProperties": { "type": "string", "pattern": "^x" } }, + "data": { "a": "y" }, + "valid": true, + "warnings": [{ "pointer": "/additionalProperties", "keyword": "pattern" }] + }, + { + "name": "unsupported-deeply-nested", + "schema": { "type": "object", "properties": { "a": { "type": "object", "properties": { "b": { "type": "array", "items": { "type": "object", "properties": { "c": { "type": "string", "pattern": "^z" } } } } } } } }, + "data": { "a": { "b": [{ "c": "q" }] } }, + "valid": true, + "warnings": [{ "pointer": "/properties/a/properties/b/items/properties/c", "keyword": "pattern" }] + }, + { + "name": "unsupported-pointer-escapes-property-names", + "schema": { "type": "object", "properties": { "a/b": { "type": "string", "pattern": "x" }, "m~n": { "type": "string", "format": "x" } } }, + "data": { "a/b": "q", "m~n": "q" }, + "valid": true, + "warnings": [ + { "pointer": "/properties/a~1b", "keyword": "pattern" }, + { "pointer": "/properties/m~0n", "keyword": "format" } + ] + }, + { + "name": "unsupported-container-is-one-warning-and-not-descended", + "schema": { "type": "object", "properties": { "a": { "type": "string" } }, "allOf": [{ "type": "integer", "pattern": "x" }], "definitions": { "s": { "type": "string", "format": "x" } } }, + "data": { "a": "s" }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "allOf" }, + { "pointer": "", "keyword": "definitions" } + ] + }, + { + "name": "unsupported-ref-is-ignored", + "schema": { "type": "object", "properties": { "a": { "$ref": "#/definitions/s" } }, "definitions": { "s": { "type": "string" } } }, + "data": { "a": 5 }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "definitions" }, + { "pointer": "/properties/a", "keyword": "$ref" } + ] + }, + { + "name": "unsupported-same-keyword-at-two-places-warns-twice", + "schema": { "type": "object", "pattern": "x", "properties": { "a": { "type": "string", "pattern": "x" } } }, + "data": { "a": "s" }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "pattern" }, + { "pointer": "/properties/a", "keyword": "pattern" } + ] + }, + { + "name": "annotations-never-warn", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "urn:example:order", + "title": "Order", + "description": "An order.", + "$comment": "annotation only", + "examples": [{ "order_id": 1 }], + "default": {}, + "type": "object", + "properties": { + "order_id": { "type": "integer", "title": "Order id", "description": "The id.", "$comment": "c", "default": 0, "examples": [1] } + } + }, + "data": { "order_id": 7 }, + "valid": true + }, + { + "name": "x-prefixed-keywords-never-warn", + "schema": { + "type": "object", + "x-owner": "billing", + "properties": { + "email": { "type": "string", "x-gdpr-sensitive": true }, + "tags": { "type": "array", "items": { "type": "string", "x-gdpr-sensitive": { "category": "pii" } } } + }, + "additionalProperties": { "type": "string", "x-custom": 1 } + }, + "data": { "email": "a@b.c", "tags": ["x"], "extra": "s" }, + "valid": true + }, + { + "name": "annotation-and-unsupported-mixed-warns-only-for-unsupported", + "schema": { "type": "object", "title": "T", "x-gdpr-sensitive": true, "properties": { "email": { "type": "string", "description": "d", "x-gdpr-sensitive": true, "format": "email" } } }, + "data": { "email": "q" }, + "valid": true, + "warnings": [{ "pointer": "/properties/email", "keyword": "format" }] + }, + { + "name": "property-names-are-not-keywords", + "schema": { "type": "object", "properties": { "title": { "type": "string" }, "pattern": { "type": "string" }, "format": { "type": "string" }, "x-gdpr-sensitive": { "type": "string" } } }, + "data": { "title": "t", "pattern": "p", "format": "f", "x-gdpr-sensitive": "s" }, + "valid": true + }, + { + "name": "all-supported-keywords-produce-no-warnings", + "schema": { + "type": "object", + "required": ["name", "score", "kind"], + "properties": { + "name": { "type": "string", "minLength": 2, "maxLength": 5 }, + "score": { "type": ["number", "null"], "minimum": 0 }, + "kind": { "enum": ["a", "b"] }, + "version": { "const": 1 }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "data": { "name": "abc", "score": 0, "kind": "a", "version": 1.0, "tags": ["x"] }, + "valid": true + }, + { + "name": "max-length-ascii-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "abc" }, + "valid": true + }, + { + "name": "max-length-turkish-3cp-6-bytes-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "ğüş" }, + "valid": true + }, + { + "name": "max-length-emoji-3cp-6-utf16-units-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "πŸ˜€πŸ˜€πŸ˜€" }, + "valid": true + }, + { + "name": "max-length-emoji-4cp-max3-invalid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "πŸ˜€πŸ˜€πŸ˜€πŸ˜€" }, + "valid": false + }, + { + "name": "max-length-combining-2cp-1-grapheme-max1-invalid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 1 } } }, + "data": { "v": "e\u0301" }, + "valid": false + } + ] + }, "trim": { "description": "ADR-0032 \u00a75 (normative): the ASCII whitespace set is space, \\t, \\n, \\r, \\f, \\v; U+00A0, U+2003 and U+3000 are NOT whitespace and are part of the value. Scope: `core`. Every case has a `mode`. `make`: call the producer's `make` with `urn` (and `trace_id` when given); the URN and the trace_id are trimmed with the ASCII set, so `expect` is the resulting `job` / `trace_id`, or `\"reject\"` when the URN is empty after trimming. `decode`: decode `file`; `valid: true` means it succeeds with `expect.urn` as the resolved identity and a warning iff `expect_warning` (a blank string `job` falls back to `urn` silently, ADR-0032 \u00a76); `valid: false` means decode raises. `accepts`: `accepts(file)` MUST equal `expect_accepts` (a blank `trace_id` makes it false) while decode itself still succeeds when `expect_decode_ok` is true (an older producer's message without a usable trace_id is tolerated). `reemit`: decode, increment `attempts`, re-encode (retry/DLQ/redrive/relay) and assert `expect_preserved` (JSON Pointer -> value) byte for byte: re-emit never trims. Note on tension with urn-naming section 2: the `make` cases that use a Unicode-space or space-padded URN pin only the ASCII-trim blank rule (what `make` does with such an input); they do not endorse such strings as well-formed URNs, which urn-naming section 2 constrains separately.", "cases": [ diff --git a/src/test/resources/conformance/schema/results.example.json b/src/test/resources/conformance/schema/results.example.json index 736688d..d8caff0 100644 --- a/src/test/resources/conformance/schema/results.example.json +++ b/src/test/resources/conformance/schema/results.example.json @@ -1,7 +1,7 @@ { "sdk": "example-sdk/redis", "version": "1.9.0", - "conformance_version": "1.9.0", + "conformance_version": "1.10.0", "generated_at": "2026-10-18T09:30:00Z", "bindings": [ "redis",