json1-6 Exercise 1: A Schema for shop.json, Tested with Mutations ================================================================= Run with Node 24.18.0 with Ajv 8.20.0 (ajv/dist/2020) and ajv-formats 3.0.1; Python 3.14.0 with jsonschema 4.26.0, both installed in a scratch folder (npm install ajv ajv-formats; py -m venv .venv; pip install jsonschema): node ex1.mjs --- shop.schema.json --- { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/schemas/shop.json", "title": "Corner Shop", "type": "object", "properties": { "shop": { "type": "string", "minLength": 1 }, "open": { "type": "boolean" }, "owner": { "type": "object", "properties": { "first": { "type": "string" }, "last": { "type": "string" }, "phone": { "type": ["string", "null"], "pattern": "^[0-9 +()-]{7,20}$" } }, "required": ["first", "last"] }, "products": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/product" } } }, "required": ["shop", "open", "owner", "products"], "$defs": { "product": { "type": "object", "properties": { "sku": { "type": "string", "pattern": "^[A-Z][0-9]$" }, "name": { "type": "string", "minLength": 1 }, "price": { "type": "number", "exclusiveMinimum": 0 }, "stock": { "type": "integer", "minimum": 0 }, "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } }, "required": ["sku", "name", "price", "stock"] } } } --- v.mjs (shared helper: creates the Ajv instance) --- import { createRequire } from 'node:module'; import fs from 'node:fs'; const require = createRequire(import.meta.url); const Ajv2020 = require('ajv/dist/2020'); const addFormats = require('ajv-formats'); export function make({ formats = true, strict, allErrors = true } = {}) { const ajv = new Ajv2020({ allErrors, ...(strict === undefined ? {} : { strict }), validateFormats: formats }); if (formats) addFormats(ajv); return ajv; } export const load = f => JSON.parse(fs.readFileSync(f, 'utf8')); --- ex1.mjs --- // Exercise 1: a schema for shop.json, tested with mutated copies. Each mutation says which keyword and path should complain. import { make, load } from './v.mjs'; const schema = load('shop.schema.json'); const validate = make().compile(schema); const clone = o => JSON.parse(JSON.stringify(o)); const base = load('shop.json'); console.log('the real shop.json:', validate(base) ? 'VALID' : 'INVALID ' + JSON.stringify(validate.errors)); const mutations = [ // [label, change, expected keyword, expected instancePath (or null = the document should stay valid)] ['control: no change', d => d, null, null], ['control: an extra top-level field', d => { d.extra = 1; return d; }, null, null], ['control: phone is a string', d => { d.owner.phone = '0131 496 0000'; return d; }, null, null], ['remove "products"', d => { delete d.products; return d; }, 'required', ''], ['open is the string "yes"', d => { d.open = 'yes'; return d; }, 'type', '/open'], ['owner.last removed', d => { delete d.owner.last; return d; }, 'required', '/owner'], ['owner.phone is a number', d => { d.owner.phone = 5550100; return d; }, 'type', '/owner/phone'], ['owner.phone is "abc"', d => { d.owner.phone = 'abc'; return d; }, 'pattern', '/owner/phone'], ['products is empty', d => { d.products = []; return d; }, 'minItems', '/products'], ['product 2 has price 0', d => { d.products[2].price = 0; return d; }, 'exclusiveMinimum', '/products/2/price'], ['product 1 has stock 1.5', d => { d.products[1].stock = 1.5; return d; }, 'type', '/products/1/stock'], ['product 0 has stock -1', d => { d.products[0].stock = -1; return d; }, 'minimum', '/products/0/stock'], ['product 3 sku is "d4"', d => { d.products[3].sku = 'd4'; return d; }, 'pattern', '/products/3/sku'], ['product 0 tags repeat "hot"', d => { d.products[0].tags = ['hot', 'hot']; return d; }, 'uniqueItems', '/products/0/tags'], ['two identical products', d => { d.products.push(clone(d.products[0])); return d; }, 'uniqueItems', '/products'], ]; let right = 0; for (const [label, change, kw, path] of mutations) { const ok = validate(change(clone(base))); const errs = validate.errors || []; const hit = kw === null ? ok : (!ok && errs.some(e => e.keyword === kw && e.instancePath === path)); if (hit) right++; const seen = ok ? 'valid' : errs.map(e => (e.instancePath || '(root)') + ' ' + e.keyword).join('; '); console.log((hit ? 'as expected ' : 'SURPRISE ') + label.padEnd(38) + seen); } console.log('\n' + right + ' of ' + mutations.length + ' behaved as predicted'); Output: the real shop.json: VALID as expected control: no change valid as expected control: an extra top-level field valid as expected control: phone is a string valid as expected remove "products" (root) required as expected open is the string "yes" /open type as expected owner.last removed /owner required as expected owner.phone is a number /owner/phone type as expected owner.phone is "abc" /owner/phone pattern as expected products is empty /products minItems as expected product 2 has price 0 /products/2/price exclusiveMinimum as expected product 1 has stock 1.5 /products/1/stock type as expected product 0 has stock -1 /products/0/stock minimum as expected product 3 sku is "d4" /products/3/sku pattern as expected product 0 tags repeat "hot" /products/0/tags uniqueItems as expected two identical products /products uniqueItems 15 of 15 behaved as predicted WHY THIS WORKS AS AN ANSWER --------------------------- The real file validates first, so every later INVALID is caused by the mutation and not by the schema being too strict. Each mutation changes exactly one thing and states the keyword and the path that should complain, and the script checks both (a type error at /open, a pattern error at /products/3/sku, uniqueItems at /products for two identical products). The three controls must stay valid: an unchanged copy, an extra top-level field (the schema does not say additionalProperties: false, so extras are allowed by default) and a string phone (the phone type is [string, null]). Note the predictions were written after I had explored the keywords in the earlier tests, so 15 of 15 shows the schema does what I intended, not that I guessed blind. Not covered: format checks on this schema (it has no format keyword), documents with extra nested fields, and very large lists.