diff --git a/CHANGELOG.md b/CHANGELOG.md index 161e7daab2..04f8708a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ See [STATUS.md](server/STATUS.md) to learn more about which features will remain ## UNRELEASED +- Class constraints: a property's entry in `constraints` can describe nested JSON values with `type` (including `["string", "null"]`), `const`, `properties`, `required`, `additionalProperties`, `items` and `anyOf`, up to 16 levels deep. Every keyword works at every level and errors name the failing value by JSON pointer (`... breaks maximum on class patch at /pad/tune: ...`). JSON Schema import keeps nested schemas, nullable types and `anyOf` below a property as `json` plus constraints instead of refusing them, and export writes them back (`docs/src/schema/classes.md`, `json-schema.md`). Older nodes reject commits that put the new keywords in a class, and skip every constraint of such a class (flat ones included) when it reaches them by sync. - Loro documents: strings that start with `[`, `{`, `atomic:`, `did:ad:` or `http(s)://` are tagged as `string` in the `datatypes` map so they are no longer read back as nested resources or URLs, and JSON `null` is kept inside nested maps and lists. ## [v0.41.0-beta.8] - 2026-10-10 diff --git a/browser/data-browser/src/chunks/FormBuilder/useFormFieldPropertySync.ts b/browser/data-browser/src/chunks/FormBuilder/useFormFieldPropertySync.ts index 31c99d1744..b7b464f275 100644 --- a/browser/data-browser/src/chunks/FormBuilder/useFormFieldPropertySync.ts +++ b/browser/data-browser/src/chunks/FormBuilder/useFormFieldPropertySync.ts @@ -2,10 +2,10 @@ import { canonicalizeScheme, core, forms, - parseConstraints, Resource, setClassConstraint, Store, + type ConstraintPatch, useResource, useStore, } from '@tomic/react'; @@ -105,13 +105,20 @@ async function moveClassConstraint( return; } - const entry = parseConstraints(raw).get(canonicalizeScheme(from)); + // Move the stored JSON, not a parsed constraint: nested schemas and + // patterns have to arrive exactly as they were written. + const map: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw; + const key = canonicalizeScheme(from); + const entry = + typeof map === 'object' && map !== null + ? Object.entries(map).find(([k]) => canonicalizeScheme(k) === key)?.[1] + : undefined; - if (!entry) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { return; } - await setClassConstraint(dataClass, to, entry); + await setClassConstraint(dataClass, to, entry as ConstraintPatch); await setClassConstraint(dataClass, from, undefined); } diff --git a/browser/lib/src/class-constraints.test.ts b/browser/lib/src/class-constraints.test.ts index b5231a82e0..63ce9966e1 100644 --- a/browser/lib/src/class-constraints.test.ts +++ b/browser/lib/src/class-constraints.test.ts @@ -13,6 +13,10 @@ interface FixtureCase { value: unknown; valid: boolean; rule?: string; + /** JSON pointer of the failing value, when the case pins it. */ + path?: string; + /** The constraint itself must be refused by the parser. */ + rejected?: boolean; } const cases = JSON.parse( @@ -31,6 +35,12 @@ describe('class constraints fixture', () => { }); it.each(cases.map((c, i) => [i, c] as const))('case %i', (_i, c) => { + if (c.rejected) { + expect(() => parseConstraint(c.constraint)).toThrow(); + + return; + } + const constraint = parseConstraint(c.constraint); if (c.valid) { @@ -45,6 +55,9 @@ describe('class constraints fixture', () => { } catch (e) { expect(e).toBeInstanceOf(ConstraintError); expect((e as ConstraintError).keyword).toBe(c.rule); + + if (c.path !== undefined) + expect((e as ConstraintError).path).toBe(c.path); } }); }); @@ -109,6 +122,41 @@ describe('checkResourceConstraints', () => { ); }); + it('names the nested path in the message', () => { + const nested = new Map(store); + + nested.set(classSubject, { + 'https://atomicdata.dev/properties/shortname': 'task', + 'https://atomicdata.dev/properties/constraints': { + [nameProp]: { + type: 'object', + properties: { tune: { type: 'number', maximum: 24 } }, + }, + }, + }); + + const getNested = (subject: string) => { + const props = nested.get(subject); + + return ( + props && { subject, getClasses: () => [], get: (p: string) => props[p] } + ); + }; + + const withValue = (value: unknown) => ({ + subject: 'https://example.com/t1', + getClasses: () => [classSubject], + get: (p: string) => (p === nameProp ? value : undefined), + }); + + expect(() => + checkResourceConstraints(withValue({ tune: 24 }), getNested), + ).not.toThrow(); + expect(() => + checkResourceConstraints(withValue({ tune: 25 }), getNested), + ).toThrow(/^Value for name breaks maximum on class task at \/tune: /); + }); + it('skips classes that are not loaded', () => { expect(() => checkResourceConstraints( diff --git a/browser/lib/src/class-constraints.ts b/browser/lib/src/class-constraints.ts index 7bfd7cf2dd..113b7165fe 100644 --- a/browser/lib/src/class-constraints.ts +++ b/browser/lib/src/class-constraints.ts @@ -11,6 +11,13 @@ import type { JSONObject, JSONValue } from './value.js'; /** Every keyword a constraint may use. Anything else is rejected. */ export const CONSTRAINT_KEYWORDS = [ 'enum', + 'const', + 'type', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'anyOf', 'minimum', 'maximum', 'exclusiveMinimum', @@ -25,8 +32,39 @@ export const CONSTRAINT_KEYWORDS = [ export type ConstraintKeyword = (typeof CONSTRAINT_KEYWORDS)[number]; +/** How deep schemas may nest. A constraint nested deeper is rejected. */ +export const MAX_DEPTH = 16; + +/** The JSON Schema `type` names a constraint accepts. */ +export const CONSTRAINT_TYPES = [ + 'string', + 'number', + 'integer', + 'boolean', + 'null', + 'object', + 'array', +] as const; + +export type ConstraintType = (typeof CONSTRAINT_TYPES)[number]; + +const TYPES: readonly string[] = CONSTRAINT_TYPES; + +/** + * A constraint. Below the top level the same shape describes a nested JSON + * Schema (`properties`, `items`, `anyOf`). + */ export interface Constraint { enum?: JSONValue[]; + const?: JSONValue; + /** Allowed JSON types, any of them. `null` makes a value nullable. */ + type?: ConstraintType[]; + properties?: Record; + required?: string[]; + /** `false` forbids unlisted keys, a schema checks them, `true` changes nothing. */ + additionalProperties?: boolean | Constraint; + items?: Constraint; + anyOf?: Constraint[]; minimum?: number; maximum?: number; exclusiveMinimum?: number; @@ -46,10 +84,12 @@ export type Constraints = Map; /** A value broke a constraint. */ export class ConstraintError extends Error { public constructor( - public readonly keyword: ConstraintKeyword, + public readonly keyword: ConstraintKeyword | 'depth', public readonly detail: string, + /** JSON pointer to the failing value: empty for the value itself, `/pad/tune` for a nested one. */ + public readonly path: string = '', ) { - super(`${keyword}: ${detail}`); + super(path ? `${keyword} at ${path}: ${detail}` : `${keyword}: ${detail}`); this.name = 'ConstraintError'; } } @@ -78,6 +118,27 @@ const COUNT_KEYWORDS = [ /** Parses one constraint object, throwing on unknown keywords and wrong types. */ export function parseConstraint(json: unknown): Constraint { + return parseAt(json, 0); +} + +function parseNested( + keyword: string, + key: string, + v: unknown, + depth: number, +): Constraint { + try { + return parseAt(v, depth + 1); + } catch (e) { + throw new Error(`in \`${keyword}\`${key}: ${(e as Error).message}`); + } +} + +function parseAt(json: unknown, depth: number): Constraint { + if (depth > MAX_DEPTH) { + throw new Error(`Constraints nest deeper than ${MAX_DEPTH} levels`); + } + if (!isObject(json)) { throw new Error( `A constraint must be a JSON object, got ${JSON.stringify(json)}`, @@ -125,7 +186,88 @@ export function parseConstraint(json: unknown): Constraint { `Constraint \`pattern\` is not a valid regex: ${(e as Error).message}`, ); } + } else if (keyword === 'const') { + out.const = v; + } else if (keyword === 'type') { + const names = Array.isArray(v) ? v : [v]; + const types: ConstraintType[] = []; + + for (const name of names) { + if (!(TYPES as readonly unknown[]).includes(name)) { + throw new Error( + `Constraint \`type\` must be one of ${TYPES.join(', ')} or an array of them, got ${JSON.stringify(v)}`, + ); + } + + if (!types.includes(name as ConstraintType)) { + types.push(name as ConstraintType); + } + } + + if (types.length === 0) { + throw new Error('Constraint `type` cannot be an empty array'); + } + + out.type = types; + } else if (keyword === 'properties') { + if (!isObject(v)) { + throw new Error( + `Constraint \`properties\` must be an object, got ${JSON.stringify(v)}`, + ); + } + + const props: Record = {}; + + for (const [name, schema] of Object.entries(v)) { + props[name] = parseNested( + 'properties', + ` for "${name}"`, + schema, + depth, + ); + } + + out.properties = props; + } else if (keyword === 'required') { + if (!Array.isArray(v) || !v.every(n => typeof n === 'string')) { + throw new Error( + `Constraint \`required\` must be an array of strings, got ${JSON.stringify(v)}`, + ); + } + + out.required = v as string[]; + } else if (keyword === 'additionalProperties') { + if (typeof v === 'boolean') { + out.additionalProperties = v; + } else if (isObject(v)) { + out.additionalProperties = parseNested( + 'additionalProperties', + '', + v, + depth, + ); + } else { + throw new Error( + `Constraint \`additionalProperties\` must be a boolean or a schema, got ${JSON.stringify(v)}`, + ); + } + } else if (keyword === 'items') { + out.items = parseNested('items', '', v, depth); + } else if (keyword === 'anyOf') { + if (!Array.isArray(v) || v.length === 0) { + throw new Error( + `Constraint \`anyOf\` must be a non-empty array, got ${JSON.stringify(v)}`, + ); + } + + out.anyOf = v.map(branch => parseNested('anyOf', '', branch, depth)); } else if (keyword === 'class') { + if (depth > 0) { + throw new Error( + 'Constraint `class` only applies to a property, not inside a nested schema', + ); + } + if (typeof v !== 'string') { throw new Error( `Constraint \`class\` must be a string, got ${JSON.stringify(v)}`, @@ -175,28 +317,98 @@ export function parseConstraints(json: unknown): Constraints { const canonical = (v: unknown): unknown => typeof v === 'string' ? canonicalizeScheme(v) : v; -const jsonEq = (a: unknown, b: unknown): boolean => - JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); +const jsonEq = (a: unknown, b: unknown): boolean => { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => jsonEq(x, b[i])); + } + + if (isObject(a) && isObject(b)) { + const keys = Object.keys(a); -/** Unwraps `{ '@id': subject }` list items so arrays of links compare as subjects. */ + return ( + keys.length === Object.keys(b).length && + keys.every(k => k in b && jsonEq(a[k], b[k])) + ); + } + + return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); +}; + +const pointerOf = (path: string[]): string => + path.map(s => `/${s.replaceAll('~', '~0').replaceAll('/', '~1')}`).join(''); + +const typeMatches = (name: ConstraintType, v: unknown): boolean => { + switch (name) { + case 'string': + return typeof v === 'string'; + case 'boolean': + return typeof v === 'boolean'; + case 'null': + return v === null; + case 'object': + return isObject(v); + case 'array': + return Array.isArray(v); + case 'number': + return isNumber(v); + case 'integer': + return isNumber(v) && Number.isInteger(v); + } +}; + +const typeNameOf = (v: unknown): string => + v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v; + +/** + * Unwraps `{ '@id': subject }` list items so arrays of links compare as + * subjects. Only a bare link is unwrapped: an object with more keys is data (a + * nested resource, or JSON that happens to have an `@id` key) and is checked as + * an object, as `atomic_lib` does. + */ const plain = (v: unknown): unknown => - isObject(v) && typeof v['@id'] === 'string' ? v['@id'] : v; + isObject(v) && typeof v['@id'] === 'string' && Object.keys(v).length === 1 + ? v['@id'] + : v; /** * Checks a value, throwing a {@link ConstraintError}. A keyword that does not - * apply to the value's type is ignored, as in JSON Schema. For an array, - * `enum` applies to every item. + * apply to the value's type is ignored, as in JSON Schema. For a top-level + * array, `enum` applies to every item; below the top level it applies to the + * value as a whole, as in JSON Schema. Nested schemas (`properties`, `items`, + * `anyOf`) are checked recursively and the error carries the JSON pointer of + * the failing value. */ export function checkValue(c: Constraint, rawValue: unknown): void { const value = Array.isArray(rawValue) ? rawValue.map(plain) : plain(rawValue); + checkAt(c, value, [], 0); +} + +function checkAt( + c: Constraint, + value: unknown, + path: string[], + depth: number, +): void { + const fail = ( + keyword: ConstraintError['keyword'], + detail: string, + at: string[] = path, + ): never => { + throw new ConstraintError(keyword, detail, pointerOf(at)); + }; + + if (depth > MAX_DEPTH) { + fail('depth', `constraints nest deeper than ${MAX_DEPTH} levels`); + } + if (c.enum) { const allowed = c.enum; - const items = Array.isArray(value) ? value : [value]; + const items = Array.isArray(value) && depth === 0 ? value : [value]; for (const item of items) { if (!allowed.some(a => jsonEq(a, item))) { - throw new ConstraintError( + fail( 'enum', `${JSON.stringify(item)} is not one of ${JSON.stringify(allowed)}`, ); @@ -204,66 +416,105 @@ export function checkValue(c: Constraint, rawValue: unknown): void { } } + if (c.const !== undefined && !jsonEq(c.const, value)) { + fail('const', `${JSON.stringify(value)} is not ${JSON.stringify(c.const)}`); + } + + if (c.type && !c.type.some(t => typeMatches(t, value))) { + fail('type', `${typeNameOf(value)} is not of type ${c.type.join(' or ')}`); + } + if (isNumber(value)) { if (c.minimum !== undefined && value < c.minimum) { - throw new ConstraintError('minimum', `${value} is below ${c.minimum}`); + fail('minimum', `${value} is below ${c.minimum}`); } if (c.maximum !== undefined && value > c.maximum) { - throw new ConstraintError('maximum', `${value} is above ${c.maximum}`); + fail('maximum', `${value} is above ${c.maximum}`); } if (c.exclusiveMinimum !== undefined && value <= c.exclusiveMinimum) { - throw new ConstraintError( - 'exclusiveMinimum', - `${value} is not above ${c.exclusiveMinimum}`, - ); + fail('exclusiveMinimum', `${value} is not above ${c.exclusiveMinimum}`); } if (c.exclusiveMaximum !== undefined && value >= c.exclusiveMaximum) { - throw new ConstraintError( - 'exclusiveMaximum', - `${value} is not below ${c.exclusiveMaximum}`, - ); + fail('exclusiveMaximum', `${value} is not below ${c.exclusiveMaximum}`); } } else if (typeof value === 'string') { // Length in Unicode code points, like Rust's `chars().count()`. const length = [...value].length; if (c.minLength !== undefined && length < c.minLength) { - throw new ConstraintError( - 'minLength', - `length ${length} is below ${c.minLength}`, - ); + fail('minLength', `length ${length} is below ${c.minLength}`); } if (c.maxLength !== undefined && length > c.maxLength) { - throw new ConstraintError( - 'maxLength', - `length ${length} is above ${c.maxLength}`, - ); + fail('maxLength', `length ${length} is above ${c.maxLength}`); } if (c.pattern && !c.pattern.test(value)) { - throw new ConstraintError( + fail( 'pattern', `${JSON.stringify(value)} does not match ${c.pattern.source}`, ); } } else if (Array.isArray(value)) { if (c.minItems !== undefined && value.length < c.minItems) { - throw new ConstraintError( - 'minItems', - `${value.length} items is below ${c.minItems}`, - ); + fail('minItems', `${value.length} items is below ${c.minItems}`); } if (c.maxItems !== undefined && value.length > c.maxItems) { - throw new ConstraintError( - 'maxItems', - `${value.length} items is above ${c.maxItems}`, + fail('maxItems', `${value.length} items is above ${c.maxItems}`); + } + + if (c.items) { + value.forEach((item, i) => + checkAt(c.items!, item, [...path, String(i)], depth + 1), ); } + } else if (isObject(value)) { + for (const name of c.required ?? []) { + if (!Object.hasOwn(value, name)) + fail('required', `missing property ${JSON.stringify(name)}`); + } + + for (const key of Object.keys(value).sort()) { + const listed = + c.properties && Object.hasOwn(c.properties, key) + ? c.properties[key] + : undefined; + const extra = c.additionalProperties; + let schema: Constraint | undefined = listed; + + if (!listed && extra === false) { + fail('additionalProperties', 'property is not allowed', [...path, key]); + } else if (!listed && isObject(extra)) { + schema = extra as Constraint; + } + + if (schema) checkAt(schema, value[key], [...path, key], depth + 1); + } + } + + if (c.anyOf) { + const reasons: string[] = []; + + for (const branch of c.anyOf) { + try { + checkAt(branch, value, path, depth + 1); + + return; + } catch (e) { + if (!(e instanceof ConstraintError)) throw e; + + reasons.push(e.message); + } + } + + fail( + 'anyOf', + `matches none of ${c.anyOf.length} alternatives (${reasons.join('; ')})`, + ); } } @@ -316,8 +567,10 @@ export function checkResourceConstraints( classSubject, ); + const at = e.path ? ` at ${e.path}` : ''; + throw new Error( - `Value for ${shortname} breaks ${e.keyword} on class ${className}: ${e.detail}`, + `Value for ${shortname} breaks ${e.keyword} on class ${className}${at}: ${e.detail}`, ); } } diff --git a/browser/lib/src/effective-constraint.ts b/browser/lib/src/effective-constraint.ts index d752eb0470..4e1b4585b8 100644 --- a/browser/lib/src/effective-constraint.ts +++ b/browser/lib/src/effective-constraint.ts @@ -80,6 +80,15 @@ function tighten(a: Constraint, b: Constraint): Constraint { out.pattern ??= b.pattern; out.class ??= b.class; + // Nested schemas cannot be combined either: the first class wins. + if (out.const === undefined) out.const = b.const; + out.type ??= b.type; + out.properties ??= b.properties; + out.required ??= b.required; + out.additionalProperties ??= b.additionalProperties; + out.items ??= b.items; + out.anyOf ??= b.anyOf; + return out; } @@ -191,9 +200,19 @@ function definedOnly(c: Constraint | undefined): Constraint { export type ConstraintPatch = { [K in ConstraintKeyword]?: K extends 'pattern' ? string | RegExp | undefined - : Constraint[K] | undefined; + : K extends NestedKeyword + ? JSONValue | undefined + : Constraint[K] | undefined; }; +/** Keywords that hold a nested JSON Schema or its type, written as plain JSON. */ +type NestedKeyword = + | 'type' + | 'properties' + | 'additionalProperties' + | 'items' + | 'anyOf'; + const isObject = (v: unknown): v is JSONObject => typeof v === 'object' && v !== null && !Array.isArray(v); diff --git a/browser/lib/src/schema-json-schema.ts b/browser/lib/src/schema-json-schema.ts index 328d9b1029..12a1368423 100644 --- a/browser/lib/src/schema-json-schema.ts +++ b/browser/lib/src/schema-json-schema.ts @@ -1,5 +1,10 @@ import { Datatype } from './datatypes.js'; -import { parseConstraint, parseConstraints } from './class-constraints.js'; +import { + CONSTRAINT_TYPES, + MAX_DEPTH, + parseConstraint, + parseConstraints, +} from './class-constraints.js'; import { core } from './ontologies/core.js'; import { canonicalizeScheme } from './subject.js'; import { @@ -96,11 +101,43 @@ const PROPERTY_KEYS = new Set([ 'description', 'x-atomic-datatype', 'x-atomic-class', + 'properties', + 'required', + 'additionalProperties', + 'anyOf', + ...CONSTRAINT_PASSTHROUGH, + ...ANNOTATIONS, + ...INFORMATIONAL, +]); + +/** Keywords of a schema nested inside a json value (below a property). */ +const NESTED_SCHEMA_KEYS = new Set([ + 'type', + 'title', + 'description', + 'enum', + 'const', + 'properties', + 'required', + 'additionalProperties', + 'items', + 'anyOf', ...CONSTRAINT_PASSTHROUGH, ...ANNOTATIONS, ...INFORMATIONAL, ]); +/** Keywords that make a schema a nested one, stored whole in the class constraints. */ +const NESTED_KEYWORDS = [ + 'properties', + 'required', + 'additionalProperties', + 'items', + 'anyOf', +]; + +const SCHEMA_TYPES: readonly string[] = CONSTRAINT_TYPES; + const REF_KEYS = new Set([ '$ref', 'title', @@ -236,6 +273,161 @@ function enumOf(schema: Obj, pointer: string): unknown[] | undefined { return undefined; } +/** + * Reads the nested keywords of `node` into `out`: `properties`, `required`, + * `additionalProperties`, `items` and `anyOf`, each validated by pointer. + * `depth` is the nesting level of `node` itself. + */ +function readNestedKeywords( + node: Obj, + pointer: string, + depth: number, + withItems: boolean, + out: Record, +): void { + if (node.properties !== undefined) { + const at = child(pointer, 'properties'); + + if (!isPlainObject(node.properties)) { + fail(at, 'expected an object of schemas'); + } + + const read: Record = {}; + + for (const [key, value] of Object.entries(node.properties)) { + read[key] = readNested(value, child(at, key), depth + 1); + } + + out.properties = read; + } + + if (node.required !== undefined) { + const at = child(pointer, 'required'); + + if (!Array.isArray(node.required)) { + fail(at, 'expected an array of property names'); + } + + node.required.forEach((name, index) => { + if (typeof name !== 'string') { + fail(child(at, index), 'expected a property name'); + } + }); + + out.required = node.required as JSONValue; + } + + if (node.additionalProperties !== undefined) { + out.additionalProperties = + typeof node.additionalProperties === 'boolean' + ? node.additionalProperties + : readNested( + node.additionalProperties, + child(pointer, 'additionalProperties'), + depth + 1, + ); + } + + if (withItems && node.items !== undefined) { + out.items = readNested(node.items, child(pointer, 'items'), depth + 1); + } + + if (node.anyOf !== undefined) { + const at = child(pointer, 'anyOf'); + + if (!Array.isArray(node.anyOf)) fail(at, 'expected an array of schemas'); + + if (node.anyOf.length === 0) fail(at, 'anyOf needs at least one schema'); + + out.anyOf = node.anyOf.map((branch, index) => + readNested(branch, child(at, index), depth + 1), + ); + } +} + +const isPlainObject = (v: unknown): v is Obj => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * Reads a schema nested inside a json value into the constraint vocabulary of + * `class-constraints`. Annotations are dropped; everything Atomic cannot check + * (`$ref`, `oneOf`, `format`, ...) is refused by JSON pointer. + */ +function readNested( + schema: unknown, + pointer: string, + depth: number, +): Record { + if (depth > MAX_DEPTH) { + fail(pointer, `schemas nest deeper than ${MAX_DEPTH} levels`); + } + + const s = asObject(schema, pointer); + + checkKeys(s, pointer, NESTED_SCHEMA_KEYS); + + const out: Record = {}; + + if (s.type !== undefined) { + const names = Array.isArray(s.type) ? s.type : [s.type]; + + if (names.length === 0) { + fail( + child(pointer, 'type'), + 'expected a type name or a non-empty array of them', + ); + } + + for (const name of names) { + if (typeof name !== 'string' || !SCHEMA_TYPES.includes(name)) { + fail( + child(pointer, 'type'), + `unsupported type ${JSON.stringify(name)}. Supported: ${SCHEMA_TYPES.join(', ')}`, + ); + } + } + + out.type = s.type as JSONValue; + } + + if (s.enum !== undefined && s.const !== undefined) { + fail(pointer, "'enum' and 'const' cannot be combined"); + } + + if (s.enum !== undefined) { + if (!Array.isArray(s.enum)) + fail(child(pointer, 'enum'), 'expected an array'); + + out.enum = s.enum as JSONValue; + } + + if (s.const !== undefined) out.const = s.const as JSONValue; + + for (const key of CONSTRAINT_PASSTHROUGH) { + if (s[key] !== undefined) out[key] = s[key] as JSONValue; + } + + readNestedKeywords(s, pointer, depth, true, out); + + // The children were checked on their own, so an error is about this node. + try { + parseConstraint(out); + } catch (e) { + fail(pointer, (e as Error).message); + } + + return out; +} + +/** Whether the `items` schema of an array needs the nested reader. */ +function itemsAreNested(items: Obj): boolean { + return ( + Object.keys(items).some( + k => NESTED_KEYWORDS.includes(k) || CONSTRAINT_PASSTHROUGH.includes(k), + ) || Array.isArray(items.type) + ); +} + function readProperty( schema: unknown, pointer: string, @@ -249,24 +441,54 @@ function readProperty( const name = optionalString(s, 'title', pointer); const description = optionalString(s, 'description', pointer); const constraint: Record = {}; - const type = s.type; + // `type` is a name, or an array of names where "null" makes the value + // nullable and several other names make it a union. + const typeList: string[] = []; + const typeIsArray = Array.isArray(s.type); + + if (Array.isArray(s.type)) { + for (const typeName of s.type) { + if (typeof typeName !== 'string' || !SCHEMA_TYPES.includes(typeName)) { + fail( + child(pointer, 'type'), + `unsupported type ${JSON.stringify(typeName)}. Supported: ${SCHEMA_TYPES.join(', ')}`, + ); + } - if (Array.isArray(type)) { - fail( - child(pointer, 'type'), - 'type arrays (such as nullable types) are not supported. Atomic has no null: leave the property out of the value instead', - ); + if (!typeList.includes(typeName)) typeList.push(typeName); + } + + if (typeList.length === 0) { + fail(child(pointer, 'type'), 'expected at least one type'); + } + } else if (s.type !== undefined) { + if (typeof s.type !== 'string') { + fail(child(pointer, 'type'), 'expected a string'); + } + + typeList.push(s.type); } - if (type !== undefined && typeof type !== 'string') { - fail(child(pointer, 'type'), 'expected a string'); + const nonNull = typeList.filter(t => t !== 'null'); + const union = nonNull.length > 1; + const nullable = typeList.length > nonNull.length && nonNull.length > 0; + const type: string | undefined = union + ? undefined + : (nonNull[0] ?? typeList[0]); + + for (const key of ['properties', 'required', 'additionalProperties']) { + if (key in s && type !== undefined && type !== 'object') { + fail(child(pointer, key), `'${key}' only applies to objects`); + } } let inferred: string | undefined; let values: unknown[] | undefined; let linkClass: unknown; - const isArray = type === 'array' || (type === undefined && 'items' in s); + const isArray = + !union && (type === 'array' || (type === undefined && 'items' in s)); + const nested: Record = {}; if (hasRef) { inferred = Datatype.ATOMIC_URL; @@ -303,9 +525,15 @@ function readProperty( const itemsPointer = child(pointer, 'items'); const items = asObject(s.items, itemsPointer); - checkKeys(items, itemsPointer, ITEM_KEYS); + if (itemsAreNested(items)) { + nested.items = readNested(items, itemsPointer, 1); + } else { + checkKeys(items, itemsPointer, ITEM_KEYS); + } - if ('$ref' in items) { + if (nested.items !== undefined) { + // Checked by the nested reader; nothing maps to a link here. + } else if ('$ref' in items) { for (const key of Object.keys(items)) { if (!['$ref', 'enum', 'const'].includes(key)) { fail(child(itemsPointer, key), `not supported next to '$ref'`); @@ -319,7 +547,7 @@ function readProperty( linkClass = items['x-atomic-class']; } - values = enumOf(items, itemsPointer); + if (nested.items === undefined) values = enumOf(items, itemsPointer); } } else { linkClass = s['x-atomic-class']; @@ -356,7 +584,8 @@ function readProperty( inferred = Datatype.JSON; break; case undefined: - if (values) inferred = datatypeOfValues(values, pointer); + if (union || 'anyOf' in s) inferred = Datatype.JSON; + else if (values) inferred = datatypeOfValues(values, pointer); break; default: @@ -375,6 +604,17 @@ function readProperty( if (s[key] !== undefined) constraint[key] = s[key] as JSONValue; } + if (!hasRef) { + readNestedKeywords(s, pointer, 0, false, nested); + } + + if (Object.keys(nested).length > 0 || (typeIsArray && (union || nullable))) { + // The nested schema checks the type too, so keep it next to it. + if (s.type !== undefined) constraint.type = s.type as JSONValue; + + Object.assign(constraint, nested); + } + if (values) constraint.enum = values as JSONValue[]; if (linkClass !== undefined) { diff --git a/docs/src/schema/classes.md b/docs/src/schema/classes.md index 3006c8a013..0720024807 100644 --- a/docs/src/schema/classes.md +++ b/docs/src/schema/classes.md @@ -122,6 +122,13 @@ Each value is an object of the keywords below. | `minItems` | integer `>= 0` | arrays | fewer items than the bound | | `maxItems` | integer `>= 0` | arrays | more items than the bound | | `pattern` | regex string | strings | the string has no match (unanchored, like JSON Schema) | +| `const` | any JSON value | any value | the value is not equal to it | +| `type` | `string`, `number`, `integer`, `boolean`, `null`, `object`, `array`, or an array of them | any value | the value has none of the types. `["string", "null"]` is a nullable string | +| `properties` | object of constraints | objects | a listed key holds a value that breaks its constraint | +| `required` | array of keys | objects | a listed key is missing | +| `additionalProperties` | `false`, `true` or a constraint | objects | `false`: a key not in `properties` is present. A constraint: such a value breaks it | +| `items` | constraint | arrays | an item breaks the constraint | +| `anyOf` | non-empty array of constraints | any value | the value breaks every alternative | | `class` | class subject | links | never checked, see below | These are JSON Schema keywords with the same meaning, except that `class` stands in for `$ref` and `enum` also accepts a list of subjects. @@ -135,12 +142,54 @@ Avoid lookaround and backreferences (Rust rejects them) and write `[0-9]` instea `class` names the class a link should point to. It is for pickers and forms, like `classtype` on a Property, and is **not** enforced when a value is written. +### Nested values + +A `json` property (or an array) can hold structure, and the same constraint language describes it. +`properties`, `items`, `additionalProperties` and the branches of `anyOf` are themselves constraints, so every keyword above works at every level. +That is JSON Schema, minus `$ref`, `oneOf`, `allOf`, `not`, `format` and the other keywords not listed here. +`class` is only allowed at the top level of a property's entry. + +```json +{ + "https://example.com/properties/pad": { + "type": "object", + "required": ["tune"], + "additionalProperties": false, + "properties": { + "tune": { "type": "number", "minimum": -24, "maximum": 24 }, + "label": { "type": ["string", "null"], "maxLength": 8 } + } + }, + "https://example.com/properties/fx": { + "type": "array", + "maxItems": 8, + "items": { + "type": "object", + "required": ["mix"], + "properties": { "mix": { "type": "number", "minimum": 0, "maximum": 1 } } + } + }, + "https://example.com/properties/source": { + "anyOf": [{ "type": "string", "pattern": "^s" }, { "type": "integer", "minimum": 0 }] + } +} +``` + +- `type` is checked first, so `{ "tune": "high" }` breaks `type` before any `minimum` is looked at. As elsewhere, a keyword that does not apply to the value (a `minimum` on a string) is ignored. +- `integer` accepts any number without a fractional part (`2.0`). `null` is a value like any other: a `null` only passes when `type` allows it, and a property that is absent is not checked at all. +- Below the top level, `enum` and `const` compare the value as a whole (as in JSON Schema). Only at the top level does `enum` on an array apply to every item. Objects and arrays compare deeply and numbers by value. +- Keys of an object are checked in alphabetical order, so the reported error is the same in every implementation. `required` is checked before `properties`. +- A schema may nest at most 16 levels deep. A deeper one is rejected when the Class is written. +- When two classes both constrain a property, nested keywords cannot be combined: the first class wins (as for `pattern`). +- Errors name the failing value by JSON pointer: `Value for pad breaks maximum on class patch at /tune: 25 is above 24`. The keyword for a failed alternative list is `anyOf`, and its detail lists why each alternative failed. Pointers escape `~` as `~0` and `/` as `~1`. + ### Enforcement - **Commit time.** The server checks every value of a resource against the constraints of each class in its `isA`, next to the check for `requires`, and rejects the commit with `Value for breaks on class : `. - A Class commit is rejected when its own `constraints` map has an unknown keyword or a wrong type. + A Class commit is rejected when its own `constraints` map has an unknown keyword or a wrong type, at any level. - **Never on sync.** Reconciling drives and importing peer data does not run these checks, and neither do commits received over a peer connection. A replica keeps what another node already accepted. - **Unloaded classes are skipped in the client.** Before signing, `@tomic/lib` runs the same checks for classes already in the local store and throws the same message. It never fetches a class for this, so the server stays the authority. +- **Older nodes.** A node from before nested constraints (v0.41.0-beta.8 and earlier) knows only the flat keywords. Its server refuses a commit that writes `type`, `const`, `properties`, `required`, `additionalProperties`, `items` or `anyOf` into a class's `constraints`, with "Unknown constraint keyword". When such a class reaches it anyway (by sync, or written elsewhere), it cannot parse the map and skips the whole class: none of that class's constraints are enforced there, the flat ones (`minimum`, `pattern`, ...) included. Other classes are unaffected. - **Legacy.** A Property's own `allowsOnly` and `max` keep working as before and are still not enforced. Both implementations (`atomic_lib` and `@tomic/lib`) run the same cases from `lib/tests/fixtures/class-constraints.json`. diff --git a/docs/src/schema/json-schema.md b/docs/src/schema/json-schema.md index e8275b4411..20ea7b7ce6 100644 --- a/docs/src/schema/json-schema.md +++ b/docs/src/schema/json-schema.md @@ -68,6 +68,9 @@ Both implementations run the cases in `lib/tests/fixtures/json-schema-interop.js | `type: array` of `$ref` | `resourceArray` plus a `class` constraint | | `type: array` of `{ type: string, format: uri }` | `resourceArray` | | Other `type: array`, `type: object` | `json` | +| `type: ["string", "null"]` (one type plus `"null"`) | the datatype of that type, plus a `type` constraint that keeps the `null` | +| `type: ["string", "integer"]`, or `anyOf` without a `type` | `json`, plus the `type` or `anyOf` constraint | +| `properties`, `required`, `additionalProperties`, `items`, `anyOf` below a property | `json`, plus the same keywords as a [nested constraint](classes.md#nested-values) | | `enum`, `const` | `enum` constraint. Without a `type` the datatype follows the values. On an array, put it on `items` | | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` | the same constraint keyword | | `minLength`, `maxLength`, `pattern` | the same constraint keyword | @@ -78,22 +81,44 @@ That also means two classes that both declare `name` as a string share one Prope `$comment`, `examples`, `default`, `deprecated`, `readOnly`, `writeOnly` and a root `$id` are annotations. They are read and dropped. -`additionalProperties` may be a boolean and is dropped: Atomic classes are open. +On a class, `additionalProperties` may be a boolean and is dropped: Atomic classes are open. Inside a property it is kept (see [Nested schemas](#nested-schemas)). ## What does not map Each of these is an error on import, naming the keyword's JSON pointer, for example `/$defs/Invoice/properties/status/oneOf`. -- `oneOf`, `anyOf`, `allOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `patternProperties`, `propertyNames`, `unevaluated*`, `prefixItems` and the other keywords that Atomic has no equivalent for. -- Nullable types (`type: ["string", "null"]`). Atomic has no null: leave the property out of the value instead. +- `oneOf`, `allOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `patternProperties`, `propertyNames`, `unevaluated*`, `prefixItems` and the other keywords that Atomic has no equivalent for. +- `anyOf` on a class (it is only read below a property), and a `type` of just `null`. - A `$ref` that is not a local `#/$defs/Name` to an object schema, a `$defs` entry that is not an object schema, and any keyword next to `$ref` other than `title`, `description` and `enum`. -- Object schemas nested inside a property (`properties` below a property). Move them to `$defs` and `$ref` them. +- A `$ref` (or `oneOf`, `format`, ...) inside a nested schema, and tuple-form `items`. Nested schemas are self-contained. +- Nesting deeper than 16 levels. - `enum` or `const` on an array. It constrains the whole array in JSON Schema, but a class constraint applies to every item. Put it on `items`. - A `format` other than `date-time`, `date` and `uri`, and any `type` other than `string`, `integer`, `number`, `boolean`, `array` and `object`. - Boolean schemas, a `$schema` other than draft 2020-12, unknown `x-` keywords, and any other keyword. - A property name that cannot become a [slug](datatypes.md#slug), two names that become the same shortname, and a shortname used with two datatypes in one Ontology (a Property is identified by shortname and datatype, and an Ontology cannot hold two with one shortname). -Items of a `json` array are not described: `{ type: array, items: { type: string } }` imports as plain `json`. +`{ type: array, items: { type: string } }` (items that are plain, with no `minLength` and the like) still imports as plain `json`. + +## Nested schemas + +A property whose schema has `properties`, `required`, `additionalProperties` or `anyOf`, whose `items` have keywords beyond a type or a link, or whose `type` is an array, keeps that schema. +The datatype is `json` (or the type's own datatype for a nullable `string`, `integer`, ...), and the schema is stored whole in the Class's constraint for the property, in the same vocabulary: `type`, `enum`, `const`, the numeric, string and array limits, `properties`, `required`, `additionalProperties`, `items` and `anyOf`. +See [nested values](classes.md#nested-values) for what is checked and when. + +```json +"pad": { + "type": "object", + "required": ["tune"], + "additionalProperties": false, + "properties": { + "tune": { "type": "number", "minimum": -24, "maximum": 24 }, + "label": { "type": ["string", "null"], "maxLength": 8 } + } +} +``` + +Inside a nested schema `title`, `description` and the annotations are read and dropped; `format` and `$ref` are errors. +Export writes the schema back next to `x-atomic-datatype: json`, so import, `ensureOntology` and export give the same nested schema again. ## `x-atomic-*` keywords diff --git a/lib/src/class_constraints.rs b/lib/src/class_constraints.rs index 188803d6fa..292b3fcb54 100644 --- a/lib/src/class_constraints.rs +++ b/lib/src/class_constraints.rs @@ -16,8 +16,15 @@ use crate::{ }; /// Every keyword a constraint may use. Anything else is rejected on write. -pub const KEYWORDS: [&str; 11] = [ +pub const KEYWORDS: [&str; 18] = [ "enum", + "const", + "type", + "properties", + "required", + "additionalProperties", + "items", + "anyOf", "minimum", "maximum", "exclusiveMinimum", @@ -30,10 +37,36 @@ pub const KEYWORDS: [&str; 11] = [ "class", ]; -/// The constraints on one property of one class. +/// How deep schemas may nest (`properties`, `items`, `anyOf`, ...). A +/// constraint nested deeper is rejected when it is parsed. +pub const MAX_DEPTH: usize = 16; + +/// The JSON Schema `type` names a constraint accepts. +pub const TYPES: [&str; 7] = [ + "string", "number", "integer", "boolean", "null", "object", "array", +]; + +/// `additionalProperties`: `false` forbids unlisted keys, a schema checks them. +/// `true` is the default and changes nothing. +#[derive(Clone, Debug)] +pub enum AdditionalProperties { + Allowed(bool), + Schema(Box), +} + +/// The constraints on one property of one class. Below the top level the same +/// struct describes a nested JSON Schema (`properties`, `items`, `anyOf`). #[derive(Clone, Debug, Default)] pub struct Constraint { pub enum_values: Option>, + pub const_value: Option, + /// Allowed JSON types, any of them. `"null"` makes a value nullable. + pub types: Option>, + pub properties: Option>, + pub required: Option>, + pub additional_properties: Option, + pub items: Option>, + pub any_of: Option>, pub minimum: Option, pub maximum: Option, pub exclusive_minimum: Option, @@ -57,11 +90,18 @@ pub struct ConstraintError { /// The JSON Schema keyword that failed. pub keyword: &'static str, pub detail: String, + /// JSON pointer to the failing value inside the property's value. Empty + /// for the value itself, `/pad/tune` for a nested one. + pub path: String, } impl std::fmt::Display for ConstraintError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: {}", self.keyword, self.detail) + if self.path.is_empty() { + write!(f, "{}: {}", self.keyword, self.detail) + } else { + write!(f, "{} at {}: {}", self.keyword, self.path, self.detail) + } } } @@ -80,6 +120,17 @@ fn count(keyword: &str, v: &Json) -> AtomicResult { /// Parses one constraint object, rejecting unknown keywords and wrong types. pub fn parse_constraint(json: &Json) -> AtomicResult { + parse_at(json, 0) +} + +fn nested(keyword: &str, key: &str, v: &Json, depth: usize) -> AtomicResult { + parse_at(v, depth + 1).map_err(|e| format!("in `{keyword}`{key}: {e}").into()) +} + +fn parse_at(json: &Json, depth: usize) -> AtomicResult { + if depth > MAX_DEPTH { + return Err(format!("Constraints nest deeper than {MAX_DEPTH} levels").into()); + } let obj = json .as_object() .ok_or_else(|| format!("A constraint must be a JSON object, got {json}"))?; @@ -111,11 +162,97 @@ pub fn parse_constraint(json: &Json) -> AtomicResult { ); } "class" => { + if depth > 0 { + return Err( + "Constraint `class` only applies to a property, not inside a nested schema" + .into(), + ); + } let s = v .as_str() .ok_or_else(|| format!("Constraint `class` must be a string, got {v}"))?; c.class = Some(canonicalize_scheme(s)); } + "const" => c.const_value = Some(v.clone()), + "type" => { + let names: Vec<&Json> = match v { + Json::Array(a) => a.iter().collect(), + single => vec![single], + }; + let mut types = Vec::new(); + for name in names { + match name.as_str().filter(|n| TYPES.contains(n)) { + Some(n) => { + if !types.iter().any(|t| t == n) { + types.push(n.to_string()); + } + } + None => { + return Err(format!( + "Constraint `type` must be one of {} or an array of them, got {v}", + TYPES.join(", ") + ) + .into()) + } + } + } + if types.is_empty() { + return Err("Constraint `type` cannot be an empty array".into()); + } + c.types = Some(types); + } + "properties" => { + let map = v + .as_object() + .ok_or_else(|| format!("Constraint `properties` must be an object, got {v}"))?; + let mut out = BTreeMap::new(); + for (name, schema) in map { + out.insert( + name.clone(), + nested("properties", &format!(" for \"{name}\""), schema, depth)?, + ); + } + c.properties = Some(out); + } + "required" => { + let arr = v + .as_array() + .ok_or_else(|| format!("Constraint `required` must be an array, got {v}"))?; + c.required = Some( + arr.iter() + .map(|n| { + n.as_str().map(str::to_string).ok_or_else(|| { + format!("Constraint `required` must hold strings, got {n}") + }) + }) + .collect::>()?, + ); + } + "additionalProperties" => { + c.additional_properties = Some(match v { + Json::Bool(b) => AdditionalProperties::Allowed(*b), + Json::Object(_) => { + AdditionalProperties::Schema(Box::new(nested("additionalProperties", "", v, depth)?)) + } + other => { + return Err(format!( + "Constraint `additionalProperties` must be a boolean or a schema, got {other}" + ) + .into()) + } + }); + } + "items" => c.items = Some(Box::new(nested("items", "", v, depth)?)), + "anyOf" => { + let arr = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| { + format!("Constraint `anyOf` must be a non-empty array, got {v}") + })?; + c.any_of = Some( + arr.iter() + .map(|b| nested("anyOf", "", b, depth)) + .collect::>()?, + ); + } other => { return Err(format!( "Unknown constraint keyword `{other}`. Allowed: {}", @@ -207,6 +344,20 @@ impl Constraint { max_items: upper(self.max_items, other.max_items), pattern: self.pattern.clone().or_else(|| other.pattern.clone()), class: self.class.clone().or_else(|| other.class.clone()), + // Nested schemas cannot be combined either: `self` wins. + const_value: self + .const_value + .clone() + .or_else(|| other.const_value.clone()), + types: self.types.clone().or_else(|| other.types.clone()), + properties: self.properties.clone().or_else(|| other.properties.clone()), + required: self.required.clone().or_else(|| other.required.clone()), + additional_properties: self + .additional_properties + .clone() + .or_else(|| other.additional_properties.clone()), + items: self.items.clone().or_else(|| other.items.clone()), + any_of: self.any_of.clone().or_else(|| other.any_of.clone()), } } @@ -224,6 +375,16 @@ impl Constraint { max_items: top.max_items.or(self.max_items), pattern: top.pattern.clone().or_else(|| self.pattern.clone()), class: top.class.clone().or_else(|| self.class.clone()), + const_value: top.const_value.clone().or_else(|| self.const_value.clone()), + types: top.types.clone().or_else(|| self.types.clone()), + properties: top.properties.clone().or_else(|| self.properties.clone()), + required: top.required.clone().or_else(|| self.required.clone()), + additional_properties: top + .additional_properties + .clone() + .or_else(|| self.additional_properties.clone()), + items: top.items.clone().or_else(|| self.items.clone()), + any_of: top.any_of.clone().or_else(|| self.any_of.clone()), } } @@ -267,6 +428,53 @@ impl Constraint { if let Some(c) = &self.class { o.insert("class".into(), Json::String(c.clone())); } + if let Some(v) = &self.const_value { + o.insert("const".into(), v.clone()); + } + if let Some(types) = &self.types { + o.insert( + "type".into(), + match types.as_slice() { + [one] => Json::String(one.clone()), + many => Json::Array(many.iter().cloned().map(Json::String).collect()), + }, + ); + } + if let Some(props) = &self.properties { + o.insert( + "properties".into(), + Json::Object( + props + .iter() + .map(|(k, v)| (k.clone(), v.to_json())) + .collect(), + ), + ); + } + if let Some(required) = &self.required { + o.insert( + "required".into(), + Json::Array(required.iter().cloned().map(Json::String).collect()), + ); + } + match &self.additional_properties { + Some(AdditionalProperties::Allowed(b)) => { + o.insert("additionalProperties".into(), Json::Bool(*b)); + } + Some(AdditionalProperties::Schema(c)) => { + o.insert("additionalProperties".into(), c.to_json()); + } + None => {} + } + if let Some(items) = &self.items { + o.insert("items".into(), items.to_json()); + } + if let Some(branches) = &self.any_of { + o.insert( + "anyOf".into(), + Json::Array(branches.iter().map(Constraint::to_json).collect()), + ); + } Json::Object(o) } } @@ -380,23 +588,93 @@ fn canonical_json(v: &Json) -> Json { fn json_eq(a: &Json, b: &Json) -> bool { match (a, b) { (Json::Number(x), Json::Number(y)) => x.as_f64() == y.as_f64(), + (Json::Array(x), Json::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(a, b)| json_eq(a, b)) + } + (Json::Object(x), Json::Object(y)) => { + x.len() == y.len() + && x.iter() + .all(|(k, a)| y.get(k).is_some_and(|b| json_eq(a, b))) + } _ => canonical_json(a) == canonical_json(b), } } +fn pointer_of(path: &[String]) -> String { + path.iter() + .map(|s| format!("/{}", s.replace('~', "~0").replace('/', "~1"))) + .collect() +} + +fn type_matches(name: &str, value: &Json) -> bool { + match (name, value) { + ("string", Json::String(_)) + | ("boolean", Json::Bool(_)) + | ("null", Json::Null) + | ("object", Json::Object(_)) + | ("array", Json::Array(_)) + | ("number", Json::Number(_)) => true, + ("integer", Json::Number(n)) => { + n.is_i64() + || n.is_u64() + || n.as_f64() + .is_some_and(|f| f.is_finite() && f.fract() == 0.0) + } + _ => false, + } +} + +fn type_name_of(value: &Json) -> &'static str { + match value { + Json::Null => "null", + Json::Bool(_) => "boolean", + Json::Number(_) => "number", + Json::String(_) => "string", + Json::Array(_) => "array", + Json::Object(_) => "object", + } +} + /// Checks a JSON value. A keyword that does not apply to the value's type is -/// ignored, as in JSON Schema. For an array, `enum` applies to every item. +/// ignored, as in JSON Schema. For a top-level array, `enum` applies to every +/// item; below the top level it applies to the value as a whole, as in JSON +/// Schema. Nested schemas (`properties`, `items`, `anyOf`) are checked +/// recursively and an error carries the JSON pointer of the failing value. pub fn check_json(c: &Constraint, value: &Json) -> Result<(), ConstraintError> { - let fail = |keyword: &'static str, detail: String| Err(ConstraintError { keyword, detail }); + check_at(c, value, &mut Vec::new(), 0) +} + +fn check_at( + c: &Constraint, + value: &Json, + path: &mut Vec, + depth: usize, +) -> Result<(), ConstraintError> { + let fail = |path: &[String], keyword: &'static str, detail: String| { + Err(ConstraintError { + keyword, + detail, + path: pointer_of(path), + }) + }; + + if depth > MAX_DEPTH { + return fail( + path, + "depth", + format!("constraints nest deeper than {MAX_DEPTH} levels"), + ); + } if let Some(allowed) = &c.enum_values { let items: Vec<&Json> = match value { - Json::Array(a) => a.iter().collect(), + Json::Array(a) if depth == 0 => a.iter().collect(), single => vec![single], }; for item in items { if !allowed.iter().any(|a| json_eq(a, item)) { return fail( + path, "enum", format!("{item} is not one of {}", Json::Array(allowed.clone())), ); @@ -404,27 +682,47 @@ pub fn check_json(c: &Constraint, value: &Json) -> Result<(), ConstraintError> { } } + if let Some(expected) = &c.const_value { + if !json_eq(expected, value) { + return fail(path, "const", format!("{value} is not {expected}")); + } + } + + if let Some(types) = &c.types { + if !types.iter().any(|t| type_matches(t, value)) { + return fail( + path, + "type", + format!( + "{} is not of type {}", + type_name_of(value), + types.join(" or ") + ), + ); + } + } + match value { Json::Number(n) => { let Some(n) = n.as_f64() else { return Ok(()) }; if let Some(m) = c.minimum { if n < m { - return fail("minimum", format!("{n} is below {m}")); + return fail(path, "minimum", format!("{n} is below {m}")); } } if let Some(m) = c.maximum { if n > m { - return fail("maximum", format!("{n} is above {m}")); + return fail(path, "maximum", format!("{n} is above {m}")); } } if let Some(m) = c.exclusive_minimum { if n <= m { - return fail("exclusiveMinimum", format!("{n} is not above {m}")); + return fail(path, "exclusiveMinimum", format!("{n} is not above {m}")); } } if let Some(m) = c.exclusive_maximum { if n >= m { - return fail("exclusiveMaximum", format!("{n} is not below {m}")); + return fail(path, "exclusiveMaximum", format!("{n} is not below {m}")); } } } @@ -432,17 +730,21 @@ pub fn check_json(c: &Constraint, value: &Json) -> Result<(), ConstraintError> { let len = s.chars().count() as u64; if let Some(m) = c.min_length { if len < m { - return fail("minLength", format!("length {len} is below {m}")); + return fail(path, "minLength", format!("length {len} is below {m}")); } } if let Some(m) = c.max_length { if len > m { - return fail("maxLength", format!("length {len} is above {m}")); + return fail(path, "maxLength", format!("length {len} is above {m}")); } } if let Some(re) = &c.pattern { if !re.is_match(s) { - return fail("pattern", format!("{s:?} does not match {}", re.as_str())); + return fail( + path, + "pattern", + format!("{s:?} does not match {}", re.as_str()), + ); } } } @@ -450,18 +752,81 @@ pub fn check_json(c: &Constraint, value: &Json) -> Result<(), ConstraintError> { let len = a.len() as u64; if let Some(m) = c.min_items { if len < m { - return fail("minItems", format!("{len} items is below {m}")); + return fail(path, "minItems", format!("{len} items is below {m}")); } } if let Some(m) = c.max_items { if len > m { - return fail("maxItems", format!("{len} items is above {m}")); + return fail(path, "maxItems", format!("{len} items is above {m}")); + } + } + if let Some(items) = &c.items { + for (i, item) in a.iter().enumerate() { + path.push(i.to_string()); + check_at(items, item, path, depth + 1)?; + path.pop(); + } + } + } + Json::Object(o) => { + for name in c.required.iter().flatten() { + if !o.contains_key(name) { + return fail(path, "required", format!("missing property {name:?}")); + } + } + let mut keys: Vec<&String> = o.keys().collect(); + keys.sort(); + for key in keys { + let listed = c.properties.as_ref().and_then(|p| p.get(key)); + let schema = match (listed, &c.additional_properties) { + (Some(schema), _) => Some(schema), + (None, Some(AdditionalProperties::Allowed(false))) => { + path.push(key.clone()); + let err = fail( + path, + "additionalProperties", + "property is not allowed".into(), + ); + path.pop(); + return err; + } + (None, Some(AdditionalProperties::Schema(schema))) => Some(schema.as_ref()), + (None, _) => None, + }; + if let Some(schema) = schema { + path.push(key.clone()); + check_at(schema, &o[key], path, depth + 1)?; + path.pop(); } } } _ => {} } + if let Some(branches) = &c.any_of { + let mut reasons = Vec::new(); + for branch in branches { + match check_at(branch, value, &mut path.clone(), depth + 1) { + Ok(()) => { + reasons.clear(); + break; + } + Err(e) => reasons.push(e.to_string()), + } + } + if !reasons.is_empty() { + return fail( + path, + "anyOf", + format!( + "matches none of {} alternatives ({})", + branches.len(), + reasons.join("; ") + ), + ); + } + } + Ok(()) } @@ -538,8 +903,13 @@ pub async fn check_resource(store: &impl Storelike, resource: &Resource) -> Atom .get(urls::SHORTNAME) .map(|v| v.to_string()) .unwrap_or(class_subject.clone()); + let at = if e.path.is_empty() { + String::new() + } else { + format!(" at {}", e.path) + }; return Err(format!( - "Value for {shortname} breaks {} on class {class_name}: {}", + "Value for {shortname} breaks {} on class {class_name}{at}: {}", e.keyword, e.detail ) .into()); @@ -593,6 +963,10 @@ mod test { assert!(cases.len() > 50); for case in &cases { + if case.get("rejected").and_then(Json::as_bool) == Some(true) { + parse_constraint(&case["constraint"]).unwrap_err(); + continue; + } let constraint = parse_constraint(&case["constraint"]).unwrap(); let valid = case["valid"].as_bool().unwrap(); let rule = case.get("rule").and_then(Json::as_str); @@ -606,7 +980,12 @@ mod test { ] { match (&result, valid) { (Ok(()), true) => {} - (Err(e), false) => assert_eq!(Some(e.keyword), rule, "case {case}"), + (Err(e), false) => { + assert_eq!(Some(e.keyword), rule, "case {case}"); + if let Some(path) = case.get("path").and_then(Json::as_str) { + assert_eq!(e.path, path, "case {case}"); + } + } _ => panic!("case {case} gave {result:?}"), } } @@ -679,6 +1058,48 @@ mod test { "{err}" ); } + + #[tokio::test] + async fn check_resource_names_the_nested_path() { + let store = crate::Db::init_temp("class_constraints_nested") + .await + .unwrap(); + store.populate().await.unwrap(); + class_with( + &store, + "https://example.com/Patch", + json!({ urls::NAME: { "type": "object", "properties": { + "tune": { "type": "number", "minimum": -24, "maximum": 24 } } } }), + ) + .await; + + let mut patch = Resource::new("https://example.com/patch1".into()); + patch + .set_unsafe( + urls::IS_A.into(), + Value::ResourceArray(vec![SubResource::Subject( + "https://example.com/Patch".into(), + )]), + ) + .unwrap(); + patch + .set_unsafe(urls::NAME.into(), Value::Json(json!({ "tune": 24 }))) + .unwrap(); + check_resource(&store, &patch).await.unwrap(); + + patch + .set_unsafe(urls::NAME.into(), Value::Json(json!({ "tune": 25 }))) + .unwrap(); + let err = check_resource(&store, &patch) + .await + .unwrap_err() + .to_string(); + assert!( + err.starts_with("Value for name breaks maximum on class thing at /tune:"), + "{err}" + ); + } + async fn class_with(store: &crate::Db, subject: &str, constraints: Json) { let mut class = Resource::new_instance(urls::CLASS, store).await.unwrap(); class.set_subject(subject.into()); diff --git a/lib/src/schema/json_schema.rs b/lib/src/schema/json_schema.rs index 8ae050cc8a..b242ea2f8e 100644 --- a/lib/src/schema/json_schema.rs +++ b/lib/src/schema/json_schema.rs @@ -24,7 +24,7 @@ use serde_json::{Map, Number, Value as Json}; use crate::{ agents::Agent, - class_constraints::{parse_constraint, parse_constraints}, + class_constraints::{parse_constraint, parse_constraints, MAX_DEPTH, TYPES as SCHEMA_TYPES}, commit::{Commit, CommitBuilder, CommitOpts}, datatype::{match_datatype, DataType}, errors::AtomicResult, @@ -316,11 +316,197 @@ fn property_keys() -> Vec<&'static str> { "description", "x-atomic-datatype", "x-atomic-class", + "properties", + "required", + "additionalProperties", + "anyOf", + ]); + keys.extend(CONSTRAINT_PASSTHROUGH); + keys +} + +/// Keywords of a schema nested inside a json value (below a property). +fn nested_keys() -> Vec<&'static str> { + let mut keys = allowed(&[ + "type", + "title", + "description", + "enum", + "const", + "properties", + "required", + "additionalProperties", + "items", + "anyOf", ]); keys.extend(CONSTRAINT_PASSTHROUGH); keys } +/// Keywords that make a schema a nested one, stored whole in the class +/// constraints instead of being mapped to a datatype. +const NESTED_KEYWORDS: [&str; 5] = [ + "properties", + "required", + "additionalProperties", + "items", + "anyOf", +]; + +/// Reads the nested keywords of `node` into `out`: `properties`, `required`, +/// `additionalProperties`, `items` and `anyOf`, each validated by pointer. +/// `depth` is the nesting level of `node` itself. +fn read_nested_keywords( + node: &Node, + pointer: &str, + depth: usize, + with_items: bool, + out: &mut Map, +) -> AtomicResult<()> { + if let Some(properties) = node.get("properties") { + let at = child(pointer, "properties"); + let Node::Object(entries) = properties else { + return fail(&at, "expected an object of schemas"); + }; + let mut read = Map::new(); + for (key, value) in entries { + read.insert( + key.clone(), + read_nested(value, &child(&at, key), depth + 1)?, + ); + } + out.insert("properties".into(), Json::Object(read)); + } + if let Some(required) = node.get("required") { + let at = child(pointer, "required"); + let Node::Array(names) = required else { + return fail(&at, "expected an array of property names"); + }; + for (index, name) in names.iter().enumerate() { + if !matches!(name, Node::String(_)) { + return fail(&child(&at, &index.to_string()), "expected a property name"); + } + } + out.insert("required".into(), required.to_json()); + } + if let Some(additional) = node.get("additionalProperties") { + let at = child(pointer, "additionalProperties"); + out.insert( + "additionalProperties".into(), + match additional { + Node::Bool(b) => Json::Bool(*b), + other => read_nested(other, &at, depth + 1)?, + }, + ); + } + if let Some(items) = node.get("items").filter(|_| with_items) { + out.insert( + "items".into(), + read_nested(items, &child(pointer, "items"), depth + 1)?, + ); + } + if let Some(any_of) = node.get("anyOf") { + let at = child(pointer, "anyOf"); + let Node::Array(branches) = any_of else { + return fail(&at, "expected an array of schemas"); + }; + if branches.is_empty() { + return fail(&at, "anyOf needs at least one schema"); + } + let mut read = Vec::new(); + for (index, branch) in branches.iter().enumerate() { + read.push(read_nested( + branch, + &child(&at, &index.to_string()), + depth + 1, + )?); + } + out.insert("anyOf".into(), Json::Array(read)); + } + Ok(()) +} + +/// Reads a schema nested inside a json value into the constraint vocabulary of +/// `class_constraints`. Annotations are dropped; everything Atomic cannot check +/// (`$ref`, `oneOf`, `format`, ...) is refused by JSON pointer. +fn read_nested(schema: &Node, pointer: &str, depth: usize) -> AtomicResult { + if depth > MAX_DEPTH { + return fail( + pointer, + format!("schemas nest deeper than {MAX_DEPTH} levels"), + ); + } + let s = as_object(schema, pointer)?; + check_keys(s, pointer, &nested_keys())?; + let mut out = Map::new(); + + match s.get("type") { + None => {} + Some(Node::String(_)) => {} + Some(Node::Array(names)) if !names.is_empty() => {} + Some(_) => { + return fail( + &child(pointer, "type"), + "expected a type name or a non-empty array of them", + ) + } + } + if let Some(ty) = s.get("type") { + let names: Vec<&Node> = match ty { + Node::Array(names) => names.iter().collect(), + single => vec![single], + }; + for name in names { + if !name.as_str().is_some_and(|n| SCHEMA_TYPES.contains(&n)) { + return fail( + &child(pointer, "type"), + format!( + "unsupported type {}. Supported: {}", + name.to_json(), + SCHEMA_TYPES.join(", ") + ), + ); + } + } + out.insert("type".into(), ty.to_json()); + } + if s.has("enum") && s.has("const") { + return fail(pointer, "'enum' and 'const' cannot be combined"); + } + match s.get("enum") { + Some(values @ Node::Array(_)) => { + out.insert("enum".into(), values.to_json()); + } + Some(_) => return fail(&child(pointer, "enum"), "expected an array"), + None => {} + } + if let Some(value) = s.get("const") { + out.insert("const".into(), value.to_json()); + } + for key in CONSTRAINT_PASSTHROUGH { + if let Some(value) = s.get(key) { + out.insert(key.to_string(), value.to_json()); + } + } + read_nested_keywords(s, pointer, depth, true, &mut out)?; + + // The children were checked on their own, so an error is about this node. + if let Err(e) = parse_constraint(&Json::Object(out.clone())) { + return fail(pointer, e.to_string()); + } + Ok(Json::Object(out)) +} + +/// Whether the `items` schema of an array needs the nested reader instead of +/// the one-level mapping to a link or an option list. +fn items_are_nested(items: &Node) -> bool { + items + .keys() + .iter() + .any(|k| NESTED_KEYWORDS.contains(k) || CONSTRAINT_PASSTHROUGH.contains(k)) + || matches!(items.get("type"), Some(Node::Array(_))) +} + fn ref_keys() -> Vec<&'static str> { allowed(&["$ref", "title", "description", "enum", "const"]) } @@ -504,23 +690,69 @@ fn read_property(schema: &Node, pointer: &str, ctx: &ImportContext) -> AtomicRes let description = optional_string(s, "description", pointer)?; let mut constraint = Map::new(); - let type_name = match s.get("type") { - None => None, - Some(Node::String(t)) => Some(t.as_str()), - Some(Node::Array(_)) => { - return fail( - &child(pointer, "type"), - "type arrays (such as nullable types) are not supported. Atomic has no null: leave the property out of the value instead", - ) + // `type` is a name, or an array of names where "null" makes the value + // nullable and several other names make it a union. + let mut type_list: Vec = Vec::new(); + let mut type_is_array = false; + match s.get("type") { + None => {} + Some(Node::String(t)) => type_list.push(t.clone()), + Some(Node::Array(names)) => { + type_is_array = true; + for name in names { + match name { + Node::String(t) if SCHEMA_TYPES.contains(&t.as_str()) => { + if !type_list.contains(t) { + type_list.push(t.clone()); + } + } + other => { + return fail( + &child(pointer, "type"), + format!( + "unsupported type {}. Supported: {}", + other.to_json(), + SCHEMA_TYPES.join(", ") + ), + ) + } + } + } + if type_list.is_empty() { + return fail(&child(pointer, "type"), "expected at least one type"); + } } Some(_) => return fail(&child(pointer, "type"), "expected a string"), + } + let non_null: Vec<&String> = type_list.iter().filter(|t| *t != "null").collect(); + let union = non_null.len() > 1; + let nullable = type_list.len() > non_null.len() && !non_null.is_empty(); + let type_name: Option<&str> = if union { + None + } else { + non_null + .first() + .copied() + .or(type_list.first()) + .map(|t| t.as_str()) }; + for key in ["properties", "required", "additionalProperties"] { + if s.has(key) && type_name.is_some_and(|t| t != "object") { + return fail( + &child(pointer, key), + format!("'{key}' only applies to objects"), + ); + } + } + let mut inferred: Option = None; let mut values: Option> = None; let mut link_class: Option = None; - let is_array = type_name == Some("array") || (type_name.is_none() && s.has("items")); + let is_array = + !union && (type_name == Some("array") || (type_name.is_none() && s.has("items"))); + let mut nested = Map::new(); if has_ref { inferred = Some(urls::ATOMIC_URL.into()); @@ -557,9 +789,16 @@ fn read_property(schema: &Node, pointer: &str, ctx: &ImportContext) -> AtomicRes if let Some(items) = s.get("items") { let items_pointer = child(pointer, "items"); let items = as_object(items, &items_pointer)?; - check_keys(items, &items_pointer, &ITEM_KEYS)?; - if items.has("$ref") { + if items_are_nested(items) { + nested.insert("items".into(), read_nested(items, &items_pointer, 1)?); + } else { + check_keys(items, &items_pointer, &ITEM_KEYS)?; + } + + if nested.contains_key("items") { + // Checked by the nested reader; nothing maps to a link here. + } else if items.has("$ref") { for key in items.keys() { if !["$ref", "enum", "const"].contains(&key) { return fail(&child(&items_pointer, key), "not supported next to '$ref'"); @@ -578,7 +817,9 @@ fn read_property(schema: &Node, pointer: &str, ctx: &ImportContext) -> AtomicRes inferred = Some(urls::RESOURCE_ARRAY.into()); link_class = items.get("x-atomic-class").map(Node::to_json); } - values = enum_of(items, &items_pointer)?; + if !nested.contains_key("items") { + values = enum_of(items, &items_pointer)?; + } } } else { link_class = s.get("x-atomic-class").map(Node::to_json); @@ -610,7 +851,9 @@ fn read_property(schema: &Node, pointer: &str, ctx: &ImportContext) -> AtomicRes Some("boolean") => inferred = Some(urls::BOOLEAN.into()), Some("object") => inferred = Some(urls::JSON.into()), None => { - if let Some(values) = &values { + if union || s.has("anyOf") { + inferred = Some(urls::JSON.into()); + } else if let Some(values) = &values { inferred = Some(datatype_of_values(values, pointer)?); } } @@ -632,6 +875,17 @@ fn read_property(schema: &Node, pointer: &str, ctx: &ImportContext) -> AtomicRes constraint.insert(key.to_string(), value.to_json()); } } + if !has_ref { + // `items` is left to the array branch, which reads it itself. + read_nested_keywords(s, pointer, 0, false, &mut nested)?; + } + if !nested.is_empty() || (type_is_array && (union || nullable)) { + // The nested schema checks the type too, so keep it next to it. + if let Some(ty) = s.get("type") { + constraint.insert("type".into(), ty.to_json()); + } + constraint.extend(nested); + } if let Some(values) = values { constraint.insert("enum".into(), Json::Array(values)); } diff --git a/lib/tests/fixtures/class-constraints.json b/lib/tests/fixtures/class-constraints.json index d215bfdfa6..8ef2986a44 100644 --- a/lib/tests/fixtures/class-constraints.json +++ b/lib/tests/fixtures/class-constraints.json @@ -815,5 +815,1607 @@ "value": -6, "valid": false, "rule": "minimum" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 0 + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 24, + "name": "abcdefgh", + "mode": "b", + "label": null + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": -24, + "label": "abc" + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 25 + }, + "valid": false, + "rule": "maximum", + "path": "/tune" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": -24.5 + }, + "valid": false, + "rule": "minimum", + "path": "/tune" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": "high" + }, + "valid": false, + "rule": "type", + "path": "/tune" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": {}, + "valid": false, + "rule": "required", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 1, + "extra": 1 + }, + "valid": false, + "rule": "additionalProperties", + "path": "/extra" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 1, + "name": "abcdefghi" + }, + "valid": false, + "rule": "maxLength", + "path": "/name" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 1, + "mode": "c" + }, + "valid": false, + "rule": "enum", + "path": "/mode" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 1, + "label": "abcd" + }, + "valid": false, + "rule": "maxLength", + "path": "/label" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": { + "tune": 1, + "label": 5 + }, + "valid": false, + "rule": "type", + "path": "/label" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": [ + 1 + ], + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "name": { + "type": "string", + "maxLength": 8 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 3 + } + }, + "additionalProperties": false + }, + "value": null, + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [], + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + { + "mix": 0 + }, + { + "mix": 1, + "kind": "reverb" + }, + { + "mix": 0.5, + "extra": true + } + ], + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + { + "mix": 0.2 + }, + { + "mix": 1.5 + } + ], + "valid": false, + "rule": "maximum", + "path": "/1/mix" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + { + "mix": 0.2 + }, + {} + ], + "valid": false, + "rule": "required", + "path": "/1" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + { + "mix": 0.2, + "kind": "delay" + } + ], + "valid": false, + "rule": "const", + "path": "/0/kind" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + "x" + ], + "valid": false, + "rule": "type", + "path": "/0" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": [ + { + "mix": 0 + }, + { + "mix": 0 + }, + { + "mix": 0 + }, + { + "mix": 0 + } + ], + "valid": false, + "rule": "maxItems", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + } + } + }, + "value": { + "mix": 0 + }, + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "properties": { + "a": { + "properties": { + "b/c": { + "items": { + "minimum": 0 + } + } + } + } + } + }, + "value": { + "a": { + "b/c": [ + 1, + 2, + -1 + ] + } + }, + "valid": false, + "rule": "minimum", + "path": "/a/b~1c/2" + }, + { + "note": "nested", + "constraint": { + "properties": { + "a~b": { + "maximum": 1 + } + } + }, + "value": { + "a~b": 2 + }, + "valid": false, + "rule": "maximum", + "path": "/a~0b" + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": 3, + "valid": true + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": "xyz", + "valid": true + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": -1, + "valid": false, + "rule": "anyOf", + "path": "" + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": "abc", + "valid": false, + "rule": "anyOf", + "path": "" + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": 1.5, + "valid": false, + "rule": "anyOf", + "path": "" + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + }, + "value": null, + "valid": false, + "rule": "anyOf", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "properties": { + "v": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "pattern": "^x" + } + ] + } + } + }, + "value": { + "v": "nope" + }, + "valid": false, + "rule": "anyOf", + "path": "/v" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "additionalProperties": { + "type": "number", + "maximum": 10 + } + }, + "value": {}, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "additionalProperties": { + "type": "number", + "maximum": 10 + } + }, + "value": { + "a": 1, + "b": 10 + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "additionalProperties": { + "type": "number", + "maximum": 10 + } + }, + "value": { + "a": 11 + }, + "valid": false, + "rule": "maximum", + "path": "/a" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "additionalProperties": { + "type": "number", + "maximum": 10 + } + }, + "value": { + "a": "1" + }, + "valid": false, + "rule": "type", + "path": "/a" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "properties": { + "k": { + "type": "integer" + } + }, + "additionalProperties": true + }, + "value": { + "k": 1, + "z": "free" + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "object", + "properties": { + "k": { + "type": "integer" + } + } + }, + "value": { + "k": 1.5 + }, + "valid": false, + "rule": "type", + "path": "/k" + }, + { + "note": "nested", + "constraint": { + "type": "object", + "properties": { + "k": { + "type": "integer" + } + } + }, + "value": { + "k": 2.0 + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "value": null, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "value": 5, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "value": 0, + "valid": false, + "rule": "minimum", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "value": "5", + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "items": { + "enum": [ + [ + 1 + ], + [ + 2 + ] + ] + } + }, + "value": [ + [ + 1 + ], + [ + 2 + ] + ], + "valid": true + }, + { + "note": "nested", + "constraint": { + "items": { + "enum": [ + [ + 1 + ], + [ + 2 + ] + ] + } + }, + "value": [ + [ + 1 + ], + [ + 3 + ] + ], + "valid": false, + "rule": "enum", + "path": "/1" + }, + { + "note": "nested", + "constraint": { + "const": { + "a": [ + 1, + 2 + ] + } + }, + "value": { + "a": [ + 1, + 2.0 + ] + }, + "valid": true + }, + { + "note": "nested", + "constraint": { + "const": { + "a": [ + 1, + 2 + ] + } + }, + "value": { + "a": [ + 2, + 1 + ] + }, + "valid": false, + "rule": "const", + "path": "" + }, + { + "note": "nested", + "constraint": { + "const": "x" + }, + "value": "x", + "valid": true + }, + { + "note": "nested", + "constraint": { + "const": null + }, + "value": null, + "valid": true + }, + { + "note": "nested", + "constraint": { + "const": null + }, + "value": 0, + "valid": false, + "rule": "const", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "boolean" + }, + "value": true, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": "boolean" + }, + "value": 1, + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": [ + "string", + "number" + ] + }, + "value": 3, + "valid": true + }, + { + "note": "nested", + "constraint": { + "type": [ + "string", + "number" + ] + }, + "value": [], + "valid": false, + "rule": "type", + "path": "" + }, + { + "note": "nested", + "constraint": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number", + "minimum": 0 + } + } + }, + "value": [ + [ + 1, + 2 + ], + [ + 3, + -4 + ] + ], + "valid": false, + "rule": "minimum", + "path": "/1/1" + }, + { + "note": "nested", + "constraint": { + "enum": [ + "a", + "b" + ] + }, + "value": [ + "a", + "b" + ], + "valid": true + }, + { + "note": "nested", + "constraint": { + "enum": [ + "a", + "b" + ] + }, + "value": [ + "a", + "c" + ], + "valid": false, + "rule": "enum", + "path": "" + }, + { + "note": "nested", + "constraint": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": {} + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "value": [], + "valid": true + }, + { + "note": "nested", + "constraint": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": { + "items": {} + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "value": [], + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "type": "text" + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "type": [] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "type": [ + "string", + 1 + ] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "properties": [] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "properties": { + "a": 1 + } + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "required": "a" + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "required": [ + 1 + ] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "additionalProperties": "no" + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "additionalProperties": { + "minimun": 1 + } + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "items": [] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "items": { + "class": "x" + } + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "anyOf": [] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "anyOf": {} + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "anyOf": [ + { + "minLength": -1 + } + ] + }, + "value": null, + "valid": true, + "rejected": true + }, + { + "note": "nested", + "constraint": { + "properties": { + "a": { + "pattern": "(" + } + } + }, + "value": null, + "valid": true, + "rejected": true } ] diff --git a/lib/tests/fixtures/json-schema-interop.json b/lib/tests/fixtures/json-schema-interop.json index f3286375e7..793c788b77 100644 --- a/lib/tests/fixtures/json-schema-interop.json +++ b/lib/tests/fixtures/json-schema-interop.json @@ -802,6 +802,362 @@ } } } + }, + { + "name": "audio: nested json ranges, closed objects, unions and nullable", + "schema": { + "x-atomic-ontology": "audio", + "$defs": { + "patch": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pad": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 8 + } + }, + "additionalProperties": false + }, + "fx": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + }, + "additionalProperties": { + "type": "number" + } + } + }, + "gain": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "source": { + "anyOf": [ + { + "type": "string", + "pattern": "^s" + }, + { + "type": "integer", + "minimum": 0 + } + ] + }, + "curve": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "maxLength": 4 + } + } + } + } + } + }, + "plan": { + "shortname": "audio", + "classes": [ + { + "shortname": "patch", + "name": "patch", + "properties": [ + { + "shortname": "name", + "datatype": "https://atomicdata.dev/datatypes/string" + }, + { + "shortname": "pad", + "datatype": "https://atomicdata.dev/datatypes/json" + }, + { + "shortname": "fx", + "datatype": "https://atomicdata.dev/datatypes/json" + }, + { + "shortname": "gain", + "datatype": "https://atomicdata.dev/datatypes/float" + }, + { + "shortname": "source", + "datatype": "https://atomicdata.dev/datatypes/json" + }, + { + "shortname": "curve", + "datatype": "https://atomicdata.dev/datatypes/json" + }, + { + "shortname": "tags", + "datatype": "https://atomicdata.dev/datatypes/json" + } + ], + "requires": [], + "recommends": [ + "name", + "pad", + "fx", + "gain", + "source", + "curve", + "tags" + ], + "constraints": { + "pad": { + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 8 + } + }, + "additionalProperties": false + }, + "fx": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + }, + "additionalProperties": { + "type": "number" + } + } + }, + "gain": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "source": { + "anyOf": [ + { + "type": "string", + "pattern": "^s" + }, + { + "type": "integer", + "minimum": 0 + } + ] + }, + "curve": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "tags": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "maxLength": 4 + } + } + } + } + ] + }, + "export": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "x-atomic-ontology": "audio", + "$defs": { + "patch": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "pad": { + "x-atomic-datatype": "https://atomicdata.dev/datatypes/json", + "type": "object", + "required": [ + "tune" + ], + "properties": { + "tune": { + "type": "number", + "minimum": -24, + "maximum": 24 + }, + "mode": { + "enum": [ + "a", + "b" + ] + }, + "label": { + "type": [ + "string", + "null" + ], + "maxLength": 8 + } + }, + "additionalProperties": false + }, + "fx": { + "x-atomic-datatype": "https://atomicdata.dev/datatypes/json", + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "required": [ + "mix" + ], + "properties": { + "mix": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "const": "reverb" + } + }, + "additionalProperties": { + "type": "number" + } + } + }, + "gain": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "source": { + "x-atomic-datatype": "https://atomicdata.dev/datatypes/json", + "anyOf": [ + { + "type": "string", + "pattern": "^s" + }, + { + "type": "integer", + "minimum": 0 + } + ] + }, + "curve": { + "x-atomic-datatype": "https://atomicdata.dev/datatypes/json", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "tags": { + "x-atomic-datatype": "https://atomicdata.dev/datatypes/json", + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "maxLength": 4 + } + } + } + } + } + } } ], "rejected": [ @@ -955,27 +1311,6 @@ "pointer": "/$defs/a/properties/x/$ref", "message": "does not name an object schema" }, - { - "name": "nullable type array", - "schema": { - "x-atomic-ontology": "o", - "$defs": { - "a": { - "type": "object", - "properties": { - "x": { - "type": [ - "string", - "null" - ] - } - } - } - } - }, - "pointer": "/$defs/a/properties/x/type", - "message": "type arrays" - }, { "name": "type null", "schema": { @@ -1179,51 +1514,6 @@ "pointer": "/$defs/b/properties/id", "message": "cannot hold two properties" }, - { - "name": "nested properties", - "schema": { - "x-atomic-ontology": "o", - "$defs": { - "a": { - "type": "object", - "properties": { - "x": { - "type": "object", - "properties": { - "y": { - "type": "string" - } - } - } - } - } - } - }, - "pointer": "/$defs/a/properties/x/properties", - "message": "unsupported keyword 'properties'" - }, - { - "name": "items with properties", - "schema": { - "x-atomic-ontology": "o", - "$defs": { - "a": { - "type": "object", - "properties": { - "x": { - "type": "array", - "items": { - "type": "object", - "properties": {} - } - } - } - } - } - }, - "pointer": "/$defs/a/properties/x/items/properties", - "message": "unsupported keyword 'properties'" - }, { "name": "other dialect", "schema": { @@ -1375,6 +1665,398 @@ }, "pointer": "/required", "message": "root has none" + }, + { + "name": "nested: $ref inside properties", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "$ref": "#/$defs/a" + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y/$ref", + "message": "unsupported keyword '$ref'" + }, + { + "name": "nested: oneOf inside items", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + } + ] + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/items/oneOf", + "message": "unsupported keyword 'oneOf'" + }, + { + "name": "nested: format inside properties", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "type": "string", + "format": "email" + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y/format", + "message": "unsupported keyword 'format'" + }, + { + "name": "nested: not inside anyOf", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "anyOf": [ + { + "not": { + "type": "string" + } + } + ] + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/anyOf/0/not", + "message": "unsupported keyword 'not'" + }, + { + "name": "nested: unknown type name", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "type": "text" + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y/type", + "message": "unsupported type" + }, + { + "name": "nested: type array with an unknown name", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": [ + "string", + "text" + ] + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/type", + "message": "unsupported type" + }, + { + "name": "nested: only null", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": [ + "null" + ] + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/type", + "message": "unsupported type" + }, + { + "name": "nested: empty anyOf", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "anyOf": [] + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/anyOf", + "message": "at least one" + }, + { + "name": "nested: tuple items", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "array", + "items": [ + { + "type": "string" + } + ] + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/items", + "message": "expected a schema object" + }, + { + "name": "nested: negative minItems in a nested schema", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "type": "array", + "minItems": -1 + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y", + "message": "non-negative integer" + }, + { + "name": "nested: properties on a string", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "string", + "properties": {} + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties", + "message": "only applies to objects" + }, + { + "name": "nested: required that is not a list", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "required": "y" + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/required", + "message": "expected an array" + }, + { + "name": "nested: enum and const together", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "enum": [ + 1 + ], + "const": 1 + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y", + "message": "cannot be combined" + }, + { + "name": "nested: deeper than 16 levels", + "schema": { + "x-atomic-ontology": "o", + "$defs": { + "a": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "object", + "properties": { + "y": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "pointer": "/$defs/a/properties/x/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y/properties/y", + "message": "deeper than 16" } ] }