json1-7 Exercise 2: Payments, Combinators, Conditionals and Closed Objects ========================================================================== Run with Node 24.18.0 (Ajv 8.20.0, ajv-formats 3.0.1, ajv-errors 3.0.0, json-schema-to-typescript 16.0.0, TypeScript 7.0.2, vscode-json-languageservice 6.0.0-next.4) and Python 3.14.0 (jsonschema 4.26.0, json-schema-for-humans 1.5.1), installed in a scratch folder. --- payment.schema.json --- { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/schemas/payment.json", "type": "object", "required": ["method", "amount"], "properties": { "method": { "enum": ["card", "bank", "cash"] }, "amount": { "type": "integer", "minimum": 1 }, "currency": { "enum": ["GBP", "EUR"] } }, "allOf": [ { "if": { "properties": { "method": { "const": "card" } }, "required": ["method"] }, "then": { "required": ["card_number", "expiry"], "properties": { "card_number": { "type": "string", "pattern": "^[0-9]{16}$" }, "expiry": { "type": "string", "pattern": "^(0[1-9]|1[0-2])/[0-9]{2}$" } } } }, { "if": { "properties": { "method": { "const": "bank" } }, "required": ["method"] }, "then": { "required": ["iban"], "properties": { "iban": { "type": "string", "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$" } } } } ], "dependentRequired": { "expiry": ["card_number"], "currency": ["amount"] }, "unevaluatedProperties": false } --- pay_cases.json (label, expected verdict, document) --- [ ["card, complete", true, {"method":"card","amount":500,"card_number":"4111111111111111","expiry":"10/29"}], ["card, with currency", true, {"method":"card","amount":500,"currency":"GBP","card_number":"4111111111111111","expiry":"01/30"}], ["bank, complete", true, {"method":"bank","amount":1200,"iban":"GB29NWBK60161331926819"}], ["cash, nothing else", true, {"method":"cash","amount":50}], ["card, no card_number", false, {"method":"card","amount":500,"expiry":"10/29"}], ["card, 15-digit number", false, {"method":"card","amount":500,"card_number":"411111111111111","expiry":"10/29"}], ["card, expiry month 13", false, {"method":"card","amount":500,"card_number":"4111111111111111","expiry":"13/29"}], ["bank, no iban", false, {"method":"bank","amount":1200}], ["bank, lower-case iban", false, {"method":"bank","amount":1200,"iban":"gb29nwbk60161331926819"}], ["cash with a stray iban", false, {"method":"cash","amount":50,"iban":"GB29NWBK60161331926819"}], ["unknown method", false, {"method":"crypto","amount":50}], ["amount 0", false, {"method":"cash","amount":0}], ["amount 9.99 (not an integer)", false, {"method":"cash","amount":9.99}], ["missing amount", false, {"method":"cash"}], ["currency without amount", false, {"method":"cash","currency":"EUR"}], ["expiry without card_number", false, {"method":"cash","amount":5,"expiry":"10/29"}], ["an empty object", false, {}], ["a cash payment that is a string",false, "cash"] ] --- ex2.mjs (Ajv) --- import { createRequire } from 'node:module'; import fs from 'node:fs'; const require = createRequire(import.meta.url); const Ajv2020 = require('ajv/dist/2020'); const validate = new Ajv2020({ allErrors: true }).compile(JSON.parse(fs.readFileSync('payment.schema.json', 'utf8'))); const cases = JSON.parse(fs.readFileSync('pay_cases.json', 'utf8')); let right = 0; for (const [label, expected, doc] of cases) { const ok = validate(doc); if (ok === expected) right++; const why = ok ? '' : validate.errors.filter(e => e.keyword !== 'if').map(e => (e.instancePath || '/') + ' ' + e.keyword).join('; '); console.log((ok === expected ? 'as expected ' : 'SURPRISE ') + label.padEnd(34) + (ok ? 'valid' : 'INVALID ' + why)); } console.log(right + ' of ' + cases.length + ' as expected'); fs.writeFileSync('pay_ajv.json', JSON.stringify(cases.map(c => validate(c[2])))); Output: as expected card, complete valid as expected card, with currency valid as expected bank, complete valid as expected cash, nothing else valid as expected card, no card_number INVALID / required; / dependentRequired; / unevaluatedProperties as expected card, 15-digit number INVALID /card_number pattern; / unevaluatedProperties; / unevaluatedProperties as expected card, expiry month 13 INVALID /expiry pattern; / unevaluatedProperties; / unevaluatedProperties as expected bank, no iban INVALID / required as expected bank, lower-case iban INVALID /iban pattern; / unevaluatedProperties as expected cash with a stray iban INVALID / unevaluatedProperties as expected unknown method INVALID /method enum as expected amount 0 INVALID /amount minimum as expected amount 9.99 (not an integer) INVALID /amount type as expected missing amount INVALID / required as expected currency without amount INVALID / required; / dependentRequired as expected expiry without card_number INVALID / dependentRequired; / unevaluatedProperties as expected an empty object INVALID / required; / required as expected a cash payment that is a string INVALID / type 18 of 18 as expected --- ex2.py (Python, compares with the Ajv verdicts) --- import json from jsonschema import Draft202012Validator as V v = V(json.load(open('payment.schema.json', encoding='utf-8'))) cases = json.load(open('pay_cases.json', encoding='utf-8')) py = [v.is_valid(c[2]) for c in cases] ajv = json.load(open('pay_ajv.json')) expected = [c[1] for c in cases] print('Python: ' + str(sum(a == b for a, b in zip(py, expected))) + ' of ' + str(len(cases)) + ' as expected') print('Ajv and Python give the same verdict on all cases:', py == ajv) print('disagreements:', [cases[i][0] for i in range(len(cases)) if py[i] != ajv[i]] or 'none') bad = list(ajv); bad[3] = not bad[3] print('control (one Ajv verdict flipped on purpose): disagreements ->', [cases[i][0] for i in range(len(cases)) if py[i] != bad[i]]) Output: Python: 18 of 18 as expected Ajv and Python give the same verdict on all cases: True disagreements: none control (one Ajv verdict flipped on purpose): disagreements -> ['cash, nothing else'] --- t3.mjs (allOf, anyOf, oneOf, not) --- import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const Ajv2020 = require('ajv/dist/2020'); const addFormats = require('ajv-formats'); const mk = (opts = {}) => { const a = new Ajv2020({ allErrors: true, strict: false, ...opts }); addFormats(a); return a; }; const show = (label, schema, docs, opts) => { const v = mk(opts).compile(schema); console.log('## ' + label); for (const d of docs) { const ok = v(d); console.log(' ' + JSON.stringify(d).padEnd(34) + (ok ? 'valid' : 'INVALID')); if (!ok) for (const e of v.errors) console.log(' ' + (e.instancePath || '/').padEnd(10) + e.keyword.padEnd(16) + e.message + (e.keyword === 'oneOf' ? ' ' + JSON.stringify(e.params) : '')); } }; show('allOf: every schema must pass', { allOf: [{ type: 'string' }, { minLength: 3 }, { pattern: '^a' }] }, ['abc', 'ab', 'xyz', 5]); show('anyOf: at least one passes', { anyOf: [{ type: 'string' }, { type: 'number' }] }, ['x', 1, null]); show('oneOf: exactly one passes (disjoint)', { oneOf: [{ type: 'string' }, { type: 'number' }] }, ['x', 1, null]); show('oneOf with overlap (integer and number)', { oneOf: [{ type: 'integer' }, { type: 'number' }] }, [1, 1.5]); show('anyOf with the same overlap', { anyOf: [{ type: 'integer' }, { type: 'number' }] }, [1, 1.5]); show('not', { not: { type: 'null' } }, [null, 0, 'x']); show('nullable via anyOf / type list', { type: ['string', 'null'], minLength: 2 }, ['ab', 'a', null]); Output: ## allOf: every schema must pass "abc" valid "ab" INVALID / minLength must NOT have fewer than 3 characters "xyz" INVALID / pattern must match pattern "^a" 5 INVALID / type must be string ## anyOf: at least one passes "x" valid 1 valid null INVALID / type must be string / type must be number / anyOf must match a schema in anyOf ## oneOf: exactly one passes (disjoint) "x" valid 1 valid null INVALID / type must be string / type must be number / oneOf must match exactly one schema in oneOf {"passingSchemas":null} ## oneOf with overlap (integer and number) 1 INVALID / oneOf must match exactly one schema in oneOf {"passingSchemas":[0,1]} 1.5 valid ## anyOf with the same overlap 1 valid 1.5 valid ## not null INVALID / not must NOT be valid 0 valid "x" valid ## nullable via anyOf / type list "ab" valid "a" INVALID / minLength must NOT have fewer than 2 characters null valid --- t4.mjs (if/then/else, dependent*, additionalProperties versus unevaluatedProperties) --- import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const Ajv2020 = require('ajv/dist/2020'); const addFormats = require('ajv-formats'); const mk = (opts = {}) => { const a = new Ajv2020({ allErrors: true, strict: false, ...opts }); addFormats(a); return a; }; const show = (label, schema, docs, opts) => { const v = mk(opts).compile(schema); console.log('## ' + label); for (const d of docs) { const ok = v(d); console.log(' ' + JSON.stringify(d).padEnd(64) + (ok ? 'valid' : 'INVALID')); if (!ok) for (const e of v.errors) console.log(' ' + (e.instancePath || '/').padEnd(12) + e.keyword.padEnd(22) + e.message); } }; // if / then / else: a UK address needs a postcode, a US one needs a state const addr = { type: 'object', required: ['country'], properties: { country: { enum: ['GB', 'US', 'FR'] } }, if: { properties: { country: { const: 'GB' } } }, then: { required: ['postcode'] }, else: { if: { properties: { country: { const: 'US' } } }, then: { required: ['state', 'zip'] } } }; show('if / then / else', addr, [{ country: 'GB', postcode: 'EH1 1YZ' }, { country: 'GB' }, { country: 'US', state: 'NY', zip: '10001' }, { country: 'US', state: 'NY' }, { country: 'FR' }, {}]); console.log(" note: 'if' fails on {} too? -> if requires country const GB; with no country, 'properties' passes, so {} takes the THEN branch"); // dependentRequired / dependentSchemas show('dependentRequired: card number needs expiry and cvv', { type: 'object', dependentRequired: { card: ['expiry', 'cvv'] } }, [{}, { card: '4111' }, { card: '4111', expiry: '10/29', cvv: '123' }, { expiry: '10/29' }]); show('dependentSchemas: card needs billing as an object', { type: 'object', dependentSchemas: { card: { required: ['billing'], properties: { billing: { type: 'object' } } } } }, [{ card: 'x' }, { card: 'x', billing: 'no' }, { card: 'x', billing: {} }]); // additionalProperties:false with allOf: the classic problem const base = { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] }; const broken = { allOf: [base], properties: { name: { type: 'string' } }, additionalProperties: false }; show('additionalProperties:false next to allOf (the problem)', broken, [{ id: 1 }, { id: 1, name: 'x' }]); const fixed = { allOf: [base], properties: { name: { type: 'string' } }, unevaluatedProperties: false }; show('unevaluatedProperties:false (the fix)', fixed, [{ id: 1 }, { id: 1, name: 'x' }, { id: 1, name: 'x', extra: 1 }]); const repeated = { allOf: [{ ...base, additionalProperties: false }, { properties: { name: { type: 'string' } } }] }; show('additionalProperties:false inside each allOf branch', repeated, [{ id: 1 }, { id: 1, name: 'x' }]); // unevaluatedItems quick check show('unevaluatedItems:false with prefixItems', { prefixItems: [{ type: 'string' }], unevaluatedItems: false }, [['a'], ['a', 1]]); Output: ## if / then / else {"country":"GB","postcode":"EH1 1YZ"} valid {"country":"GB"} INVALID / required must have required property 'postcode' / if must match "then" schema {"country":"US","state":"NY","zip":"10001"} valid {"country":"US","state":"NY"} INVALID / required must have required property 'zip' / if must match "then" schema / if must match "else" schema {"country":"FR"} valid {} INVALID / required must have required property 'postcode' / if must match "then" schema / required must have required property 'country' note: 'if' fails on {} too? -> if requires country const GB; with no country, 'properties' passes, so {} takes the THEN branch ## dependentRequired: card number needs expiry and cvv {} valid {"card":"4111"} INVALID / dependentRequired must have properties expiry, cvv when property card is present / dependentRequired must have properties expiry, cvv when property card is present {"card":"4111","expiry":"10/29","cvv":"123"} valid {"expiry":"10/29"} valid ## dependentSchemas: card needs billing as an object {"card":"x"} INVALID / required must have required property 'billing' {"card":"x","billing":"no"} INVALID /billing type must be object {"card":"x","billing":{}} valid ## additionalProperties:false next to allOf (the problem) {"id":1} INVALID / additionalProperties must NOT have additional properties {"id":1,"name":"x"} INVALID / additionalProperties must NOT have additional properties ## unevaluatedProperties:false (the fix) {"id":1} valid {"id":1,"name":"x"} valid {"id":1,"name":"x","extra":1} INVALID / unevaluatedProperties must NOT have unevaluated properties ## additionalProperties:false inside each allOf branch {"id":1} valid {"id":1,"name":"x"} INVALID / additionalProperties must NOT have additional properties ## unevaluatedItems:false with prefixItems ["a"] valid ["a",1] INVALID / unevaluatedItems must NOT have more than 1 items --- t7.py (the same checks in Python) --- from jsonschema import Draft202012Validator as V def r(s, docs): v = V(s); return ' '.join(str(d) + '=' + ('ok' if v.is_valid(d) else 'NO') for d in docs) print('oneOf overlap ', r({'oneOf': [{'type': 'integer'}, {'type': 'number'}]}, [1, 1.5])) print('anyOf overlap ', r({'anyOf': [{'type': 'integer'}, {'type': 'number'}]}, [1, 1.5])) addr = {'type': 'object', 'required': ['country'], 'properties': {'country': {'enum': ['GB', 'US', 'FR']}}, 'if': {'properties': {'country': {'const': 'GB'}}}, 'then': {'required': ['postcode']}, 'else': {'if': {'properties': {'country': {'const': 'US'}}}, 'then': {'required': ['state', 'zip']}}} print('if/then/else ', r(addr, [{'country': 'GB', 'postcode': 'x'}, {'country': 'GB'}, {'country': 'US', 'state': 'NY'}, {'country': 'FR'}, {}])) print('dependentRequired ', r({'type': 'object', 'dependentRequired': {'card': ['expiry', 'cvv']}}, [{}, {'card': 1}, {'card': 1, 'expiry': 1, 'cvv': 1}])) base = {'type': 'object', 'properties': {'id': {'type': 'integer'}}, 'required': ['id']} print('addlProps + allOf ', r({'allOf': [base], 'properties': {'name': {'type': 'string'}}, 'additionalProperties': False}, [{'id': 1}, {'id': 1, 'name': 'x'}])) print('unevaluatedProps ', r({'allOf': [base], 'properties': {'name': {'type': 'string'}}, 'unevaluatedProperties': False}, [{'id': 1}, {'id': 1, 'name': 'x'}, {'id': 1, 'name': 'x', 'extra': 1}])) print('unevaluatedItems ', r({'prefixItems': [{'type': 'string'}], 'unevaluatedItems': False}, [['a'], ['a', 1]])) Output: oneOf overlap 1=NO 1.5=ok anyOf overlap 1=ok 1.5=ok if/then/else {'country': 'GB', 'postcode': 'x'}=ok {'country': 'GB'}=NO {'country': 'US', 'state': 'NY'}=NO {'country': 'FR'}=ok {}=NO dependentRequired {}=ok {'card': 1}=NO {'card': 1, 'expiry': 1, 'cvv': 1}=ok addlProps + allOf {'id': 1}=NO {'id': 1, 'name': 'x'}=NO unevaluatedProps {'id': 1}=ok {'id': 1, 'name': 'x'}=ok {'id': 1, 'name': 'x', 'extra': 1}=NO unevaluatedItems ['a']=ok ['a', 1]=NO WHY THIS WORKS AS AN ANSWER --------------------------- Each of the 18 payment documents has an expected verdict written down first, and both validators were checked against it and against each other (18 of 18 in each, no disagreements). The control (one Ajv verdict flipped on purpose) is reported as a disagreement on exactly that case, so "none" means something. The invalid cases cover each rule once: a missing method-specific field, a pattern failure, an unknown method, amount 0, a non-integer amount, a missing amount, dependentRequired, and a stray field for the wrong method (caught only because of unevaluatedProperties). In the t4 output, note that {} takes the THEN branch of the address schema because the if has no required; that is the trap described in the chapter, shown rather than explained. Note also the echo errors: when a then-branch fails, unevaluatedProperties errors appear for the properties it would have evaluated. The overlap case (oneOf rejects 1 against [integer, number]) is the usual reason to prefer anyOf. Tested with Ajv and jsonschema only.