From 66d9fa84fe721bb8e7e1c464f4b9cc6ae0c264ce Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Fri, 28 Aug 2026 13:53:46 -0700 Subject: [PATCH 1/6] add opt-in JSON Patch support --- CHANGELOG.md | 6 ++ README.md | 26 ++++++- package.json | 2 +- src/helpers.ts | 156 ++++++++++++++++++++++++++++++++++++++++- src/index.ts | 3 +- tests/09-merge.test.ts | 66 +++++++++++++++++ 6 files changed, 255 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea56e0..c15a503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.3.0] - 2026-08-28 + +### Added + +- Added opt-in `$patch` support using JSON Patch operations + ## [0.2.4] - 2025-03-24 ### Changed diff --git a/README.md b/README.md index ec56530..0e8e15c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Cabidela takes a JSON-Schema and optional configuration flags: - `errorMessages`: boolean - If true, the validator will use custom `errorMessage` messages from the schema. Default is false. - `fullErrors`: boolean - If true, the validator will be more verbose when throwing errors for complex schemas (example: anyOf, oneOf's), set to false for shorter exceptions. Default is true. - `useMerge`: boolean - Set to true if you want to use the `$merge` keyword. Default is false. See below for more information. +- `usePatch`: boolean - Set to true if you want to use the `$patch` keyword. Default is false. See below for more information. - `subSchemas`: any[] - An optional array of sub-schemas that can be used with `$id` and `$ref`. See below for more information. Returns a validation object. @@ -251,7 +252,7 @@ cabidela.validate({ }); ``` -## Combined schemas and $merge +## Combined schemas, $merge and $patch The standard way of combining and extending schemas is by using the [`allOf`](https://json-schema.org/understanding-json-schema/reference/combining#allOf) (AND), [`anyOf`](https://json-schema.org/understanding-json-schema/reference/combining#anyOf) (OR), [`oneOf`](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) (XOR) and [`not`](https://json-schema.org/understanding-json-schema/reference/combining#not) keywords, all supported by this library. @@ -299,6 +300,29 @@ new Cabidela(schema, { useMerge: true }); You can combine `$merge` with `$id` and `$ref` keywords, which get resolved first, for even more flexibility. +Cabidela also supports `$patch` using [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902). This is useful when an existing value must be replaced or removed rather than merged, such as narrowing an enum: + +```json +{ + "$patch": { + "source": { "$ref": "input" }, + "with": [ + { + "op": "replace", + "path": "/properties/reasoning_effort/enum", + "value": ["low", "high", "max"] + } + ] + } +} +``` + +Set `usePatch` to true to enable the keyword: + +```js +new Cabidela(schema, { usePatch: true }); +``` + ## Custom errors If the new instance options has the `errorMessages` flag set to true, you can use the property `errorMessage` in the schema to define custom error messages. diff --git a/package.json b/package.json index 3c47b49..9ffbd8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/cabidela", - "version": "0.2.4", + "version": "0.3.0", "description": "Cabidela is a small, fast, eval-less, Cloudflare Workers compatible, dynamic JSON Schema validator", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/helpers.ts b/src/helpers.ts index 0b05883..63b5cf3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -35,13 +35,157 @@ function deepMerge(target: any, source: any) { return result; } +type JsonPatchOperation = { + op: "add" | "remove" | "replace" | "move" | "copy" | "test"; + path: string; + from?: string; + value?: any; +}; + +const parseJsonPointer = (pointer: string): string[] => { + if (pointer === "") return []; + if (!pointer.startsWith("/")) throw new Error(`Invalid JSON Pointer '${pointer}'`); + if (/~(?:[^01]|$)/.test(pointer)) throw new Error(`Invalid JSON Pointer '${pointer}'`); + return pointer + .slice(1) + .split("/") + .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")); +}; + +const arrayIndex = (token: string, length: number, allowEnd: boolean): number => { + if (!/^(0|[1-9][0-9]*)$/.test(token)) throw new Error(`Invalid array index '${token}'`); + const index = Number(token); + if (index > length || (!allowEnd && index === length)) throw new Error(`Array index '${token}' is out of bounds`); + return index; +}; + +const getJsonPointer = (document: any, pointer: string): any => { + let value = document; + for (const token of parseJsonPointer(pointer)) { + if (Array.isArray(value)) { + value = value[arrayIndex(token, value.length, false)]; + } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { + value = value[token]; + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + } + return value; +}; + +const getJsonPointerParent = (document: any, pointer: string) => { + const path = parseJsonPointer(pointer); + if (path.length === 0) return { parent: undefined, token: undefined }; + const token = path.pop() as string; + const parentPointer = + path.length === 0 ? "" : `/${path.map((part) => part.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`; + return { parent: getJsonPointer(document, parentPointer), token }; +}; + +const addJsonPointer = (document: any, pointer: string, value: any): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return value; + if (Array.isArray(parent)) { + if (token === "-") { + parent.push(value); + } else { + parent.splice(arrayIndex(token, parent.length, true), 0, value); + } + } else if (parent !== null && typeof parent === "object") { + parent[token] = value; + } else { + throw new Error(`JSON Pointer '${pointer}' parent is not a container`); + } + return document; +}; + +const removeJsonPointer = (document: any, pointer: string): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return undefined; + if (Array.isArray(parent)) { + parent.splice(arrayIndex(token, parent.length, false), 1); + } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { + delete parent[token]; + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + return document; +}; + +const replaceJsonPointer = (document: any, pointer: string, value: any): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return value; + if (Array.isArray(parent)) { + parent[arrayIndex(token, parent.length, false)] = value; + } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { + parent[token] = value; + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + return document; +}; + +const jsonEquals = (left: any, right: any): boolean => { + if (left === right) return true; + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false; + if (Array.isArray(left) !== Array.isArray(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key])) + ); +}; + +const applyJsonPatch = (source: any, operations: JsonPatchOperation[]): any => { + if (!Array.isArray(operations)) throw new Error("$patch 'with' must be an array"); + let document = structuredClone(source); + for (const operation of operations) { + if (!operation || typeof operation.path !== "string") throw new Error("Invalid JSON patch operation"); + switch (operation.op) { + case "add": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch add operation requires 'value'"); + document = addJsonPointer(document, operation.path, structuredClone(operation.value)); + break; + case "remove": + document = removeJsonPointer(document, operation.path); + break; + case "replace": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch replace operation requires 'value'"); + document = replaceJsonPointer(document, operation.path, structuredClone(operation.value)); + break; + case "move": { + if (typeof operation.from !== "string") throw new Error("JSON patch move operation requires 'from'"); + const value = getJsonPointer(document, operation.from); + document = removeJsonPointer(document, operation.from); + document = addJsonPointer(document, operation.path, value); + break; + } + case "copy": + if (typeof operation.from !== "string") throw new Error("JSON patch copy operation requires 'from'"); + document = addJsonPointer(document, operation.path, structuredClone(getJsonPointer(document, operation.from))); + break; + case "test": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch test operation requires 'value'"); + if (!jsonEquals(getJsonPointer(document, operation.path), operation.value)) { + throw new Error(`JSON patch test failed at '${operation.path}'`); + } + break; + default: + throw new Error(`Unsupported JSON patch operation '${operation.op}'`); + } + } + return document; +}; + export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: any) => { const ts = (obj: any, cb?: any) => { + if (obj === null || typeof obj !== "object") return; let hits: number; do { hits = 0; for (const key of Object.keys(obj)) { - if (typeof obj[key] == "object") { + if (obj[key] !== null && typeof obj[key] == "object") { ts(obj[key], (value: any) => { obj[key] = value; hits++; @@ -57,6 +201,16 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: delete obj[key]; } } + if (options.usePatch && key == "$patch") { + const patch = applyJsonPatch(obj[key].source, obj[key].with); + if (cb) { + cb(patch); + } else { + hits++; + Object.assign(obj, patch); + delete obj[key]; + } + } } else { if (key == "$ref") { const { $id, $path } = parse$ref(obj[key]); diff --git a/src/index.ts b/src/index.ts index 58f67e3..8de0539 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { resolvePayload, pathToString, traverseSchema } from "./helpers"; export type CabidelaOptions = { applyDefaults?: boolean; useMerge?: boolean; + usePatch?: boolean; errorMessages?: boolean; fullErrors?: boolean; subSchemas?: Array; @@ -43,7 +44,7 @@ export class Cabidela { this.addSchema(subSchema, false); } } - if (this.options.useMerge || (this.options.subSchemas as []).length > 0) { + if (this.options.useMerge || this.options.usePatch || (this.options.subSchemas as []).length > 0) { traverseSchema(this.options, this.definitions, this.schema); } } diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index 3ec99db..862cbfc 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -74,3 +74,69 @@ describe("$merge", () => { }); }); }); + +describe("$patch", () => { + test.skipIf(process.env.AJV)("applies JSON Patch operations", () => { + let schema = { + $patch: { + source: { + type: "object", + properties: { + effort: { type: "string", enum: ["low", "medium", "high"] }, + obsolete: { type: "boolean" }, + }, + required: ["effort"], + }, + with: [ + { op: "replace", path: "/properties/effort/enum", value: ["low", "medium", "high", "max", null] }, + { op: "add", path: "/properties/effort/default", value: "max" }, + { op: "remove", path: "/properties/obsolete" }, + { op: "copy", from: "/properties/effort", path: "/properties/copied_effort" }, + { op: "move", from: "/required/0", path: "/required/0" }, + { op: "test", path: "/properties/effort/default", value: "max" }, + ], + }, + }; + const cabidela = new FakeCabidela(schema, { usePatch: true }); + schema = cabidela.getSchema(); + expect(schema).toStrictEqual({ + type: "object", + properties: { + effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, + copied_effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, + }, + required: ["effort"], + }); + }); + + test.skipIf(process.env.AJV)("resolves references before applying a patch", () => { + let schema = { + $patch: { + source: { $ref: "$defs#/input" }, + with: [{ op: "replace", path: "/properties/effort/enum", value: ["low", "high"] }], + }, + $defs: { + input: { + type: "object", + properties: { effort: { type: "string", enum: ["low", "medium", "high"] } }, + }, + }, + }; + const cabidela = new FakeCabidela(schema, { usePatch: true }); + schema = cabidela.getSchema(); + expect(schema).toStrictEqual({ + type: "object", + properties: { effort: { type: "string", enum: ["low", "high"] } }, + }); + }); + + test.skipIf(process.env.AJV)("rejects a failed test operation", () => { + const schema = { + $patch: { + source: { type: "string" }, + with: [{ op: "test", path: "/type", value: "number" }], + }, + }; + expect(() => new FakeCabidela(schema, { usePatch: true })).toThrowError("JSON patch test failed at '/type'"); + }); +}); From ab3b9a9369fee49ba20be6ac4841516bacce08a0 Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Fri, 28 Aug 2026 14:35:36 -0700 Subject: [PATCH 2/6] reject invalid JSON Patch results --- src/helpers.ts | 3 +++ tests/09-merge.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/helpers.ts b/src/helpers.ts index 63b5cf3..a922a26 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -175,6 +175,9 @@ const applyJsonPatch = (source: any, operations: JsonPatchOperation[]): any => { throw new Error(`Unsupported JSON patch operation '${operation.op}'`); } } + if (document === null || typeof document !== "object" || Array.isArray(document)) { + throw new Error("$patch result must be an object schema"); + } return document; }; diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index 862cbfc..c06cd20 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -139,4 +139,14 @@ describe("$patch", () => { }; expect(() => new FakeCabidela(schema, { usePatch: true })).toThrowError("JSON patch test failed at '/type'"); }); + + test.skipIf(process.env.AJV).each([ + { $patch: { with: [] } }, + { $patch: { source: { type: "string" }, with: [{ op: "remove", path: "" }] } }, + { $patch: { source: { type: "string" }, with: [{ op: "replace", path: "", value: false }] } }, + ])("rejects a patch that does not produce an object schema", (schema) => { + expect(() => new FakeCabidela(schema, { usePatch: true })).toThrowError( + "$patch result must be an object schema", + ); + }); }); From 19a8938ceff081f093f4817010ff20b637433731 Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Fri, 28 Aug 2026 15:28:42 -0700 Subject: [PATCH 3/6] prepare schemas after configuration changes --- src/index.ts | 38 +++++++++++++++++++++++++------------- tests/09-merge.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8de0539..6814b82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ export class Cabidela { private schema: any; private options: CabidelaOptions; private definitions: any = {}; + private localDefinitions: any; + private addedSchemas: Array = []; constructor(schema: any, options?: CabidelaOptions) { this.schema = schema; @@ -35,25 +37,21 @@ export class Cabidela { errorMessages: false, ...(options || {}), }; - if (this.schema.hasOwnProperty("$defs")) { - this.definitions["$defs"] = this.schema["$defs"]; - delete this.schema["$defs"]; - } - if ((this.options.subSchemas as []).length > 0) { - for (const subSchema of this.options.subSchemas as []) { - this.addSchema(subSchema, false); - } - } - if (this.options.useMerge || this.options.usePatch || (this.options.subSchemas as []).length > 0) { - traverseSchema(this.options, this.definitions, this.schema); - } + this.prepareSchema(true); } setSchema(schema: any) { this.schema = schema; + this.prepareSchema(true); } addSchema(subSchema: any, combine: boolean = true) { + this.addedSchemas.push(subSchema); + this.registerSchema(subSchema); + if (combine == true) traverseSchema(this.options, this.definitions, this.schema); + } + + private registerSchema(subSchema: any) { if (subSchema.hasOwnProperty("$id")) { const url = URL.parse(subSchema["$id"]); if (url) { @@ -66,7 +64,20 @@ export class Cabidela { } else { throw new Error("subSchemas need $id https://json-schema.org/understanding-json-schema/structuring#id"); } - if (combine == true) traverseSchema(this.options, this.definitions, this.schema); + } + + private prepareSchema(resetLocalDefinitions: boolean) { + if (resetLocalDefinitions) { + this.localDefinitions = this.schema["$defs"]; + delete this.schema["$defs"]; + } + this.definitions = {}; + if (this.localDefinitions !== undefined) this.definitions["$defs"] = this.localDefinitions; + for (const subSchema of this.options.subSchemas as []) this.registerSchema(subSchema); + for (const subSchema of this.addedSchemas) this.registerSchema(subSchema); + if (this.options.useMerge || this.options.usePatch || (this.options.subSchemas as []).length > 0) { + traverseSchema(this.options, this.definitions, this.schema); + } } getSchema() { @@ -75,6 +86,7 @@ export class Cabidela { setOptions(options: CabidelaOptions) { this.options = { ...this.options, ...options }; + this.prepareSchema(false); } throw(message: string, needle: SchemaNavigation) { diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index c06cd20..b6df1ef 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -1,4 +1,5 @@ import { expect, describe, test } from "vitest"; +import { Cabidela } from "../src"; import { FakeCabidela } from "./lib/fake-cabidela"; describe("$merge", () => { @@ -149,4 +150,32 @@ describe("$patch", () => { "$patch result must be an object schema", ); }); + + test("resolves patches installed through setSchema", () => { + const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); + + cabidela.setSchema({ + $patch: { + source: { type: "string", enum: ["low", "high"] }, + with: [{ op: "add", path: "/enum/-", value: "max" }], + }, + }); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); + expect(() => cabidela.validate("max")).not.toThrow(); + }); + + test("resolves existing patches when setOptions enables them", () => { + const cabidela = new Cabidela({ + $patch: { + source: { type: "string", enum: ["low", "high"] }, + with: [{ op: "add", path: "/enum/-", value: "max" }], + }, + }); + + cabidela.setOptions({ usePatch: true }); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); + expect(() => cabidela.validate("max")).not.toThrow(); + }); }); From e95ed0acfb0cef9b4efa0bdf209a16e24274da9b Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Fri, 28 Aug 2026 15:46:36 -0700 Subject: [PATCH 4/6] Make schema updates transactional --- src/helpers.ts | 14 ++++++- src/index.ts | 90 +++++++++++++++++++++++++++++++----------- tests/09-merge.test.ts | 54 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index a922a26..943cdc7 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -92,7 +92,12 @@ const addJsonPointer = (document: any, pointer: string, value: any): any => { parent.splice(arrayIndex(token, parent.length, true), 0, value); } } else if (parent !== null && typeof parent === "object") { - parent[token] = value; + Object.defineProperty(parent, token, { + value, + writable: true, + enumerable: true, + configurable: true, + }); } else { throw new Error(`JSON Pointer '${pointer}' parent is not a container`); } @@ -118,7 +123,12 @@ const replaceJsonPointer = (document: any, pointer: string, value: any): any => if (Array.isArray(parent)) { parent[arrayIndex(token, parent.length, false)] = value; } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { - parent[token] = value; + Object.defineProperty(parent, token, { + value, + writable: true, + enumerable: true, + configurable: true, + }); } else { throw new Error(`JSON Pointer '${pointer}' does not exist`); } diff --git a/src/index.ts b/src/index.ts index 6814b82..8b031ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,33 +29,46 @@ export class Cabidela { private addedSchemas: Array = []; constructor(schema: any, options?: CabidelaOptions) { - this.schema = schema; - this.options = { + const nextOptions = { fullErrors: true, subSchemas: [], applyDefaults: false, errorMessages: false, ...(options || {}), }; - this.prepareSchema(true); + const prepared = this.prepareNewSchema(schema, nextOptions, []); + this.schema = this.replaceSchema(schema, prepared.schema); + this.options = nextOptions; + this.definitions = prepared.definitions; + this.localDefinitions = prepared.localDefinitions; } setSchema(schema: any) { - this.schema = schema; - this.prepareSchema(true); + const prepared = this.prepareNewSchema(schema, this.options, this.addedSchemas); + this.schema = this.replaceSchema(schema, prepared.schema); + this.definitions = prepared.definitions; + this.localDefinitions = prepared.localDefinitions; } addSchema(subSchema: any, combine: boolean = true) { - this.addedSchemas.push(subSchema); - this.registerSchema(subSchema); - if (combine == true) traverseSchema(this.options, this.definitions, this.schema); + const addedSchemas = [...this.addedSchemas, structuredClone(subSchema)]; + const prepared = this.prepareSchema( + this.schema, + this.options, + this.localDefinitions, + addedSchemas, + combine, + ); + this.replaceSchema(this.schema, prepared.schema); + this.definitions = prepared.definitions; + this.addedSchemas = addedSchemas; } - private registerSchema(subSchema: any) { + private registerSchema(definitions: any, subSchema: any) { if (subSchema.hasOwnProperty("$id")) { const url = URL.parse(subSchema["$id"]); if (url) { - this.definitions[url.pathname.split("/").slice(-1)[0]] = subSchema; + definitions[url.pathname.split("/").slice(-1)[0]] = structuredClone(subSchema); } else { throw new Error( "subSchemas need a valid retrieval URI $id https://json-schema.org/understanding-json-schema/structuring#retrieval-uri", @@ -66,18 +79,42 @@ export class Cabidela { } } - private prepareSchema(resetLocalDefinitions: boolean) { - if (resetLocalDefinitions) { - this.localDefinitions = this.schema["$defs"]; - delete this.schema["$defs"]; + private prepareNewSchema(schema: any, options: CabidelaOptions, addedSchemas: Array) { + const candidate = structuredClone(schema); + const localDefinitions = candidate["$defs"]; + delete candidate["$defs"]; + return this.prepareSchema(candidate, options, localDefinitions, addedSchemas, true); + } + + private replaceSchema(target: any, source: any) { + for (const key of Object.keys(target)) delete target[key]; + for (const key of Object.keys(source)) { + Object.defineProperty(target, key, { + value: source[key], + writable: true, + enumerable: true, + configurable: true, + }); } - this.definitions = {}; - if (this.localDefinitions !== undefined) this.definitions["$defs"] = this.localDefinitions; - for (const subSchema of this.options.subSchemas as []) this.registerSchema(subSchema); - for (const subSchema of this.addedSchemas) this.registerSchema(subSchema); - if (this.options.useMerge || this.options.usePatch || (this.options.subSchemas as []).length > 0) { - traverseSchema(this.options, this.definitions, this.schema); + return target; + } + + private prepareSchema( + schema: any, + options: CabidelaOptions, + localDefinitions: any, + addedSchemas: Array, + combine: boolean, + ) { + const candidate = structuredClone(schema); + const definitions: any = {}; + if (localDefinitions !== undefined) definitions["$defs"] = structuredClone(localDefinitions); + for (const subSchema of options.subSchemas as []) this.registerSchema(definitions, subSchema); + for (const subSchema of addedSchemas) this.registerSchema(definitions, subSchema); + if (combine && (options.useMerge || options.usePatch || (options.subSchemas as []).length > 0)) { + traverseSchema(options, definitions, candidate); } + return { schema: candidate, definitions, localDefinitions }; } getSchema() { @@ -85,8 +122,17 @@ export class Cabidela { } setOptions(options: CabidelaOptions) { - this.options = { ...this.options, ...options }; - this.prepareSchema(false); + const nextOptions = { ...this.options, ...options }; + const prepared = this.prepareSchema( + this.schema, + nextOptions, + this.localDefinitions, + this.addedSchemas, + true, + ); + this.replaceSchema(this.schema, prepared.schema); + this.options = nextOptions; + this.definitions = prepared.definitions; } throw(message: string, needle: SchemaNavigation) { diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index b6df1ef..4d0bd89 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -178,4 +178,58 @@ describe("$patch", () => { expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); expect(() => cabidela.validate("max")).not.toThrow(); }); + + test("retains the active schema when setSchema preparation fails", () => { + const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); + + expect(() => + cabidela.setSchema({ + $patch: { + source: { type: "object" }, + with: [{ op: "remove", path: "/missing" }], + }, + }), + ).toThrowError("JSON Pointer '/missing' does not exist"); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string" }); + expect(() => cabidela.validate(42)).toThrow(); + }); + + test("does not retain an invalid added schema", () => { + const cabidela = new Cabidela({ type: "string" }); + + expect(() => cabidela.addSchema({ type: "object" })).toThrowError("subSchemas need $id"); + expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); + expect(() => cabidela.validate(42)).not.toThrow(); + }); + + test("does not retain invalid options", () => { + const cabidela = new Cabidela({ type: "string" }); + + expect(() => cabidela.setOptions({ subSchemas: [{ type: "object" }] })).toThrowError( + "subSchemas need $id", + ); + expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); + expect(() => cabidela.validate(42)).not.toThrow(); + }); + + test.each(["__proto__", "constructor", "prototype"])( + "adds %s as an own JSON Pointer member", + (property) => { + const cabidela = new Cabidela( + { + $patch: { + source: { type: "object", properties: {} }, + with: [{ op: "add", path: `/properties/${property}`, value: { type: "string" } }], + }, + }, + { usePatch: true }, + ); + const properties = cabidela.getSchema().properties; + + expect(Object.hasOwn(properties, property)).toBe(true); + expect(properties[property]).toStrictEqual({ type: "string" }); + expect(Object.getPrototypeOf(properties)).toBe(Object.prototype); + }, + ); }); From 63552ecb6789e213dc3bf98eb17818fb669a31fb Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Fri, 28 Aug 2026 15:59:34 -0700 Subject: [PATCH 5/6] Preserve root JSON Patch properties --- src/helpers.ts | 32 +++++++++++++++++--------------- tests/09-merge.test.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 943cdc7..f2011f3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -82,6 +82,18 @@ const getJsonPointerParent = (document: any, pointer: string) => { return { parent: getJsonPointer(document, parentPointer), token }; }; +const setObjectProperty = (object: any, property: string, value: any) => + Object.defineProperty(object, property, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + +const assignObjectProperties = (target: any, source: any) => { + for (const key of Object.keys(source)) setObjectProperty(target, key, source[key]); +}; + const addJsonPointer = (document: any, pointer: string, value: any): any => { const { parent, token } = getJsonPointerParent(document, pointer); if (token === undefined) return value; @@ -92,12 +104,7 @@ const addJsonPointer = (document: any, pointer: string, value: any): any => { parent.splice(arrayIndex(token, parent.length, true), 0, value); } } else if (parent !== null && typeof parent === "object") { - Object.defineProperty(parent, token, { - value, - writable: true, - enumerable: true, - configurable: true, - }); + setObjectProperty(parent, token, value); } else { throw new Error(`JSON Pointer '${pointer}' parent is not a container`); } @@ -123,12 +130,7 @@ const replaceJsonPointer = (document: any, pointer: string, value: any): any => if (Array.isArray(parent)) { parent[arrayIndex(token, parent.length, false)] = value; } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { - Object.defineProperty(parent, token, { - value, - writable: true, - enumerable: true, - configurable: true, - }); + setObjectProperty(parent, token, value); } else { throw new Error(`JSON Pointer '${pointer}' does not exist`); } @@ -210,7 +212,7 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: } else { // root level hits++; - Object.assign(obj, merge); + assignObjectProperties(obj, merge); delete obj[key]; } } @@ -220,7 +222,7 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: cb(patch); } else { hits++; - Object.assign(obj, patch); + assignObjectProperties(obj, patch); delete obj[key]; } } @@ -234,7 +236,7 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: } else { // root level hits++; - Object.assign(obj, resolvedObject); + assignObjectProperties(obj, resolvedObject); delete obj[key]; } } else { diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index 4d0bd89..d84ce8c 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -232,4 +232,21 @@ describe("$patch", () => { expect(Object.getPrototypeOf(properties)).toBe(Object.prototype); }, ); + + test.each(["__proto__", "constructor", "prototype"])("adds %s as an own root member", (property) => { + const cabidela = new Cabidela( + { + $patch: { + source: {}, + with: [{ op: "add", path: `/${property}`, value: { type: "string" } }], + }, + }, + { usePatch: true }, + ); + const schema = cabidela.getSchema(); + + expect(Object.hasOwn(schema, property)).toBe(true); + expect(schema[property]).toStrictEqual({ type: "string" }); + expect(Object.getPrototypeOf(schema)).toBe(Object.prototype); + }); }); From 81d74a0238c405fc230d169bb5fdb315d1ea6a4a Mon Sep 17 00:00:00 2001 From: Kastan Day Date: Tue, 8 Sep 2026 17:24:54 -0700 Subject: [PATCH 6/6] Prepare schemas safely --- CHANGELOG.md | 11 +- README.md | 66 +++++-- src/helpers.ts | 237 ------------------------ src/index.ts | 178 +++++++----------- src/json-patch.ts | 145 +++++++++++++++ src/schema.ts | 191 +++++++++++++++++++ tests/01-schema-lifecycle.test.ts | 110 +++++++++++ tests/09-merge.test.ts | 183 +----------------- tests/11-patch.test.ts | 298 ++++++++++++++++++++++++++++++ tests/12-json-patch.test.ts | 179 ++++++++++++++++++ tests/20-composition.test.ts | 59 ++++++ tests/60-error-messages.test.ts | 10 +- tests/70-complex-examples.test.ts | 4 +- 13 files changed, 1128 insertions(+), 543 deletions(-) create mode 100644 src/json-patch.ts create mode 100644 src/schema.ts create mode 100644 tests/01-schema-lifecycle.test.ts create mode 100644 tests/11-patch.test.ts create mode 100644 tests/12-json-patch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c15a503..478088c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,18 @@ All notable changes to this project will be documented in this file. -## [0.3.0] - 2026-08-28 +## [0.3.0] - 2026-09-08 ### Added -- Added opt-in `$patch` support using JSON Patch operations +- Added opt-in `$patch` support for all six JSON Patch operations, including array edits and schema references + +### Changed + +- Schema configuration updates prepare references and extensions before committing, preserving the root object's identity +- Schema traversal preserves literal JSON data and supports fragmentless references +- Composition validation counts successful branches and reports nested failures consistently +- Corrected the `maxProperties` error message ## [0.2.4] - 2025-03-24 diff --git a/README.md b/README.md index 0e8e15c..d35e5b6 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,14 @@ You can change the schema at any time by calling `cabidela.setSchema(schema: any You can change the options at any time by calling `cabidela.setOptions(options: CabidelaOptions)`. +`setSchema`, `setOptions`, and `addSchema` prepare references and enabled extensions before committing an update. If preparation fails, the active schema and configuration remain unchanged. Cabidela preserves the root schema object's identity, so `getSchema()` returns the supplied object with its resolved fields. Preparation copies changed branches rather than cloning an unchanged schema. + +Use plain JSON data objects for schemas. A frozen or sealed root is usable when preparation leaves it unchanged. If preparation needs to add, remove, or replace a root field, the root must permit those changes; Cabidela checks them before writing and throws if they are not allowed. Accessor properties and proxies are outside this JSON data contract. + +Register another schema with `cabidela.addSchema(subSchema)`; its `$id` must be a valid retrieval URI. References resolve immediately by default. `cabidela.addSchema(subSchema, false)` registers it for the next preparation without resolving the current schema yet. For several interdependent schemas, pass them together in `subSchemas` when constructing the validator. + +Preparation consumes `$ref`, `$merge`, and `$patch` wrappers. Changing an option later does not reconstruct an earlier source schema or undo an already applied patch; use `setSchema` to install a new source. + ### Validate payload Call `cabidela.validate(payload: any)` to validate your payload. @@ -252,7 +260,7 @@ cabidela.validate({ }); ``` -## Combined schemas, $merge and $patch +## Combined schemas and $merge The standard way of combining and extending schemas is by using the [`allOf`](https://json-schema.org/understanding-json-schema/reference/combining#allOf) (AND), [`anyOf`](https://json-schema.org/understanding-json-schema/reference/combining#anyOf) (OR), [`oneOf`](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) (XOR) and [`not`](https://json-schema.org/understanding-json-schema/reference/combining#not) keywords, all supported by this library. @@ -300,28 +308,50 @@ new Cabidela(schema, { useMerge: true }); You can combine `$merge` with `$id` and `$ref` keywords, which get resolved first, for even more flexibility. -Cabidela also supports `$patch` using [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902). This is useful when an existing value must be replaced or removed rather than merged, such as narrowing an enum: +## $patch -```json -{ - "$patch": { - "source": { "$ref": "input" }, - "with": [ +Enable `usePatch` to transform a schema with [JSON Patch (RFC 6902)](https://datatracker.ietf.org/doc/html/rfc6902). For example, narrow the allowed reasoning efforts while reusing a shared input schema: + +```js +const schema = { + $defs: { + input: { + type: "object", + properties: { + reasoning_effort: { + type: "string", + enum: ["low", "medium", "high"], + }, + }, + required: ["reasoning_effort"], + }, + }, + $patch: { + source: { $ref: "$defs#/input" }, + with: [ { - "op": "replace", - "path": "/properties/reasoning_effort/enum", - "value": ["low", "high", "max"] - } - ] - } -} + op: "replace", + path: "/properties/reasoning_effort/enum", + value: ["low", "high"], + }, + ], + }, +}; + +const cabidela = new Cabidela(schema, { usePatch: true }); +cabidela.validate({ reasoning_effort: "high" }); // true +cabidela.validate({ reasoning_effort: "medium" }); // throws ``` -Set `usePatch` to true to enable the keyword: +`with` is an ordered array of operations. All six operations are supported: `add`, `remove`, `replace`, `move`, `copy`, and `test`. A failed operation rejects the entire schema update. `null` is an ordinary JSON value; use `remove` to delete a field or array element. -```js -new Cabidela(schema, { usePatch: true }); -``` +Paths use [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901): escape `/` as `~1` and `~` as `~0`. An empty path replaces or addresses the whole source; `-` appends to an array for `add`. For example, `{ op: "remove", path: "/properties/reasoning_effort/enum/1" }` removes the second enum entry. + +The final result of each `$patch` must be an object schema. Boolean schemas, arrays, scalars, and a removed root cannot be installed as the result. This is a restriction of Cabidela's schema wrapper; JSON Patch itself also supports those document types. + +References and enabled extensions in `source` resolve before the operations run. Operation values remain literal JSON during patching. Afterward, references or extensions introduced at schema locations are resolved; data inside `default`, `const`, enum members, examples, and custom annotations is preserved. References can use `$defs#/name` for local definitions or `input` / `input#/path` for a registered schema whose retrieval URI ends in `input`. + +`$patch` can be nested in subschemas and combined with `$merge` when both flags are enabled. Existing wrappers around an entire `properties` map also work. A property named `$patch`, `$merge`, or `$ref` remains a property when its value is a schema; complete `source`/`with` wrappers at map level retain their extension meaning. ## Custom errors diff --git a/src/helpers.ts b/src/helpers.ts index f2011f3..b491321 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,3 @@ -import type { CabidelaOptions } from "."; - export type metaData = { types: Set; size: number; @@ -15,241 +13,6 @@ export const includesAll = (arr: Array, values: Array) => { return values.every((v) => arr.includes(v)); }; -// https://json-schema.org/understanding-json-schema/structuring#dollarref -export const parse$ref = (ref: string) => { - const parts = ref.split("#"); - return { - $id: parts[0], - $path: parts[1].split("/").filter((part: string) => part != ""), - }; -}; - -function deepMerge(target: any, source: any) { - const result = Array(target) && Array.isArray(source) ? target.concat(source) : { ...target, ...source }; - for (const key of Object.keys(result)) { - result[key] = - typeof target[key] == "object" && typeof source[key] == "object" - ? deepMerge(target[key], source[key]) - : structuredClone(result[key]); - } - return result; -} - -type JsonPatchOperation = { - op: "add" | "remove" | "replace" | "move" | "copy" | "test"; - path: string; - from?: string; - value?: any; -}; - -const parseJsonPointer = (pointer: string): string[] => { - if (pointer === "") return []; - if (!pointer.startsWith("/")) throw new Error(`Invalid JSON Pointer '${pointer}'`); - if (/~(?:[^01]|$)/.test(pointer)) throw new Error(`Invalid JSON Pointer '${pointer}'`); - return pointer - .slice(1) - .split("/") - .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")); -}; - -const arrayIndex = (token: string, length: number, allowEnd: boolean): number => { - if (!/^(0|[1-9][0-9]*)$/.test(token)) throw new Error(`Invalid array index '${token}'`); - const index = Number(token); - if (index > length || (!allowEnd && index === length)) throw new Error(`Array index '${token}' is out of bounds`); - return index; -}; - -const getJsonPointer = (document: any, pointer: string): any => { - let value = document; - for (const token of parseJsonPointer(pointer)) { - if (Array.isArray(value)) { - value = value[arrayIndex(token, value.length, false)]; - } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { - value = value[token]; - } else { - throw new Error(`JSON Pointer '${pointer}' does not exist`); - } - } - return value; -}; - -const getJsonPointerParent = (document: any, pointer: string) => { - const path = parseJsonPointer(pointer); - if (path.length === 0) return { parent: undefined, token: undefined }; - const token = path.pop() as string; - const parentPointer = - path.length === 0 ? "" : `/${path.map((part) => part.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`; - return { parent: getJsonPointer(document, parentPointer), token }; -}; - -const setObjectProperty = (object: any, property: string, value: any) => - Object.defineProperty(object, property, { - value, - writable: true, - enumerable: true, - configurable: true, - }); - -const assignObjectProperties = (target: any, source: any) => { - for (const key of Object.keys(source)) setObjectProperty(target, key, source[key]); -}; - -const addJsonPointer = (document: any, pointer: string, value: any): any => { - const { parent, token } = getJsonPointerParent(document, pointer); - if (token === undefined) return value; - if (Array.isArray(parent)) { - if (token === "-") { - parent.push(value); - } else { - parent.splice(arrayIndex(token, parent.length, true), 0, value); - } - } else if (parent !== null && typeof parent === "object") { - setObjectProperty(parent, token, value); - } else { - throw new Error(`JSON Pointer '${pointer}' parent is not a container`); - } - return document; -}; - -const removeJsonPointer = (document: any, pointer: string): any => { - const { parent, token } = getJsonPointerParent(document, pointer); - if (token === undefined) return undefined; - if (Array.isArray(parent)) { - parent.splice(arrayIndex(token, parent.length, false), 1); - } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { - delete parent[token]; - } else { - throw new Error(`JSON Pointer '${pointer}' does not exist`); - } - return document; -}; - -const replaceJsonPointer = (document: any, pointer: string, value: any): any => { - const { parent, token } = getJsonPointerParent(document, pointer); - if (token === undefined) return value; - if (Array.isArray(parent)) { - parent[arrayIndex(token, parent.length, false)] = value; - } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { - setObjectProperty(parent, token, value); - } else { - throw new Error(`JSON Pointer '${pointer}' does not exist`); - } - return document; -}; - -const jsonEquals = (left: any, right: any): boolean => { - if (left === right) return true; - if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false; - if (Array.isArray(left) !== Array.isArray(right)) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - return ( - leftKeys.length === rightKeys.length && - leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key])) - ); -}; - -const applyJsonPatch = (source: any, operations: JsonPatchOperation[]): any => { - if (!Array.isArray(operations)) throw new Error("$patch 'with' must be an array"); - let document = structuredClone(source); - for (const operation of operations) { - if (!operation || typeof operation.path !== "string") throw new Error("Invalid JSON patch operation"); - switch (operation.op) { - case "add": - if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch add operation requires 'value'"); - document = addJsonPointer(document, operation.path, structuredClone(operation.value)); - break; - case "remove": - document = removeJsonPointer(document, operation.path); - break; - case "replace": - if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch replace operation requires 'value'"); - document = replaceJsonPointer(document, operation.path, structuredClone(operation.value)); - break; - case "move": { - if (typeof operation.from !== "string") throw new Error("JSON patch move operation requires 'from'"); - const value = getJsonPointer(document, operation.from); - document = removeJsonPointer(document, operation.from); - document = addJsonPointer(document, operation.path, value); - break; - } - case "copy": - if (typeof operation.from !== "string") throw new Error("JSON patch copy operation requires 'from'"); - document = addJsonPointer(document, operation.path, structuredClone(getJsonPointer(document, operation.from))); - break; - case "test": - if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch test operation requires 'value'"); - if (!jsonEquals(getJsonPointer(document, operation.path), operation.value)) { - throw new Error(`JSON patch test failed at '${operation.path}'`); - } - break; - default: - throw new Error(`Unsupported JSON patch operation '${operation.op}'`); - } - } - if (document === null || typeof document !== "object" || Array.isArray(document)) { - throw new Error("$patch result must be an object schema"); - } - return document; -}; - -export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: any) => { - const ts = (obj: any, cb?: any) => { - if (obj === null || typeof obj !== "object") return; - let hits: number; - do { - hits = 0; - for (const key of Object.keys(obj)) { - if (obj[key] !== null && typeof obj[key] == "object") { - ts(obj[key], (value: any) => { - obj[key] = value; - hits++; - }); - if (options.useMerge && key == "$merge") { - const merge = deepMerge(obj[key].source, obj[key].with); - if (cb) { - cb(merge); - } else { - // root level - hits++; - assignObjectProperties(obj, merge); - delete obj[key]; - } - } - if (options.usePatch && key == "$patch") { - const patch = applyJsonPatch(obj[key].source, obj[key].with); - if (cb) { - cb(patch); - } else { - hits++; - assignObjectProperties(obj, patch); - delete obj[key]; - } - } - } else { - if (key == "$ref") { - const { $id, $path } = parse$ref(obj[key]); - const { resolvedObject } = resolvePayload($path, definitions[$id]); - if (resolvedObject) { - if (cb) { - cb(resolvedObject); - } else { - // root level - hits++; - assignObjectProperties(obj, resolvedObject); - delete obj[key]; - } - } else { - throw new Error(`Could not resolve '${obj[key]}' $ref`); - } - } - } - } - } while (hits > 0); - }; - ts(obj); -}; - /* Resolves a path in an object obj = { diff --git a/src/index.ts b/src/index.ts index 8b031ff..9eeb5eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import { resolvePayload, pathToString, traverseSchema } from "./helpers"; +import { resolvePayload, pathToString } from "./helpers"; +import { resolveSchema, replaceObject } from "./schema"; export type CabidelaOptions = { applyDefaults?: boolean; @@ -24,7 +25,6 @@ export type SchemaNavigation = { export class Cabidela { private schema: any; private options: CabidelaOptions; - private definitions: any = {}; private localDefinitions: any; private addedSchemas: Array = []; @@ -32,40 +32,33 @@ export class Cabidela { const nextOptions = { fullErrors: true, subSchemas: [], + useMerge: false, + usePatch: false, applyDefaults: false, errorMessages: false, ...(options || {}), }; const prepared = this.prepareNewSchema(schema, nextOptions, []); - this.schema = this.replaceSchema(schema, prepared.schema); + this.schema = replaceObject(schema, prepared.schema); this.options = nextOptions; - this.definitions = prepared.definitions; this.localDefinitions = prepared.localDefinitions; } setSchema(schema: any) { const prepared = this.prepareNewSchema(schema, this.options, this.addedSchemas); - this.schema = this.replaceSchema(schema, prepared.schema); - this.definitions = prepared.definitions; + this.schema = replaceObject(schema, prepared.schema); this.localDefinitions = prepared.localDefinitions; } addSchema(subSchema: any, combine: boolean = true) { const addedSchemas = [...this.addedSchemas, structuredClone(subSchema)]; - const prepared = this.prepareSchema( - this.schema, - this.options, - this.localDefinitions, - addedSchemas, - combine, - ); - this.replaceSchema(this.schema, prepared.schema); - this.definitions = prepared.definitions; + const prepared = this.prepareSchema(this.schema, this.options, this.localDefinitions, addedSchemas, combine); + replaceObject(this.schema, prepared.schema); this.addedSchemas = addedSchemas; } private registerSchema(definitions: any, subSchema: any) { - if (subSchema.hasOwnProperty("$id")) { + if (subSchema && Object.hasOwn(subSchema, "$id")) { const url = URL.parse(subSchema["$id"]); if (url) { definitions[url.pathname.split("/").slice(-1)[0]] = structuredClone(subSchema); @@ -80,25 +73,12 @@ export class Cabidela { } private prepareNewSchema(schema: any, options: CabidelaOptions, addedSchemas: Array) { - const candidate = structuredClone(schema); - const localDefinitions = candidate["$defs"]; - delete candidate["$defs"]; + const localDefinitions = schema["$defs"]; + const candidate = Object.hasOwn(schema, "$defs") ? { ...schema } : schema; + if (candidate !== schema) delete candidate["$defs"]; return this.prepareSchema(candidate, options, localDefinitions, addedSchemas, true); } - private replaceSchema(target: any, source: any) { - for (const key of Object.keys(target)) delete target[key]; - for (const key of Object.keys(source)) { - Object.defineProperty(target, key, { - value: source[key], - writable: true, - enumerable: true, - configurable: true, - }); - } - return target; - } - private prepareSchema( schema: any, options: CabidelaOptions, @@ -106,15 +86,15 @@ export class Cabidela { addedSchemas: Array, combine: boolean, ) { - const candidate = structuredClone(schema); - const definitions: any = {}; + let candidate = schema; + const definitions: any = Object.create(null); if (localDefinitions !== undefined) definitions["$defs"] = structuredClone(localDefinitions); for (const subSchema of options.subSchemas as []) this.registerSchema(definitions, subSchema); for (const subSchema of addedSchemas) this.registerSchema(definitions, subSchema); - if (combine && (options.useMerge || options.usePatch || (options.subSchemas as []).length > 0)) { - traverseSchema(options, definitions, candidate); + if (combine && (options.useMerge || options.usePatch || Object.keys(definitions).length > 0)) { + candidate = resolveSchema(options, definitions, candidate); } - return { schema: candidate, definitions, localDefinitions }; + return { schema: candidate, localDefinitions }; } getSchema() { @@ -123,16 +103,9 @@ export class Cabidela { setOptions(options: CabidelaOptions) { const nextOptions = { ...this.options, ...options }; - const prepared = this.prepareSchema( - this.schema, - nextOptions, - this.localDefinitions, - this.addedSchemas, - true, - ); - this.replaceSchema(this.schema, prepared.schema); + const prepared = this.prepareSchema(this.schema, nextOptions, this.localDefinitions, this.addedSchemas, true); + replaceObject(this.schema, prepared.schema); this.options = nextOptions; - this.definitions = prepared.definitions; } throw(message: string, needle: SchemaNavigation) { @@ -186,7 +159,7 @@ export class Cabidela { // Iterates through the properties of an "object" schema parseObject(needle: SchemaNavigation): boolean { - if (needle.schema.hasOwnProperty("minProperties")) { + if (Object.hasOwn(needle.schema, "minProperties")) { if (Object.keys(needle.payload).length < needle.schema.minProperties) { this.throw( `minProperties at '${pathToString(needle.path)}' is ${needle.schema.minProperties}, got ${Object.keys(needle.payload).length}`, @@ -195,10 +168,10 @@ export class Cabidela { } } - if (needle.schema.hasOwnProperty("maxProperties")) { + if (Object.hasOwn(needle.schema, "maxProperties")) { if (Object.keys(needle.payload).length > needle.schema.maxProperties) { this.throw( - `maxProperties at '${pathToString(needle.path)}' is ${needle.schema.minProperties}, got ${Object.keys(needle.payload).length}`, + `maxProperties at '${pathToString(needle.path)}' is ${needle.schema.maxProperties}, got ${Object.keys(needle.payload).length}`, needle, ); } @@ -207,7 +180,7 @@ export class Cabidela { const localEvaluatedProperties = new Set([] as string[]); let matchCount: number = 0; - if (needle.schema.hasOwnProperty("properties")) { + if (Object.hasOwn(needle.schema, "properties")) { for (let property in needle.schema.properties) { const matches = this.parseSubSchema({ ...needle, @@ -222,7 +195,7 @@ export class Cabidela { } // additionalProperties only recognizes properties declared in the same subschema as itself. - if (needle.schema.hasOwnProperty("additionalProperties")) { + if (Object.hasOwn(needle.schema, "additionalProperties")) { matchCount += this.parseAdditionalProperties( needle, needle.schema.additionalProperties, @@ -231,7 +204,7 @@ export class Cabidela { } // unevaluatedProperties keyword is similar to additionalProperties except that it can recognize properties declared in subschemas. - if (needle.schema.hasOwnProperty("unevaluatedProperties")) { + if (Object.hasOwn(needle.schema, "unevaluatedProperties")) { needle.evaluatedProperties = new Set([...needle.evaluatedProperties, ...localEvaluatedProperties]); matchCount += this.parseAdditionalProperties( needle, @@ -241,7 +214,7 @@ export class Cabidela { } // this has to be last - if (needle.schema.hasOwnProperty("required")) { + if (Object.hasOwn(needle.schema, "required")) { if ( new Set(needle.schema.required.map((r: string) => pathToString([...needle.path, r]))).difference( needle.evaluatedProperties.union(localEvaluatedProperties), @@ -259,20 +232,22 @@ export class Cabidela { for (let option in list) { try { - const matches = this.parseSubSchema({ + const branch = { ...needle, - schema: { type: needle.schema.type, ...list[option] }, + schema: { ...(needle.schema.type === undefined ? {} : { type: needle.schema.type }), ...list[option] }, carryProperties: false, absorvErrors: true, deferredApplyDefaults: true, - }); - rounds += matches; + defaultsCallbacks: [], + }; + this.parseSubSchema(branch); + // Validation failures throw. Property/item counts are not branch counts: + // an empty object or a multi-item array can each match one whole branch. + rounds++; + defaultsCallbacks.push(...branch.defaultsCallbacks); if (breakCondition && breakCondition(rounds)) break; - defaultsCallbacks.push(...needle.defaultsCallbacks); - needle.defaultsCallbacks = []; } catch (e: any) { needle.errors.add(e.message as string); - needle.defaultsCallbacks = []; } } for (const callback of defaultsCallbacks) callback(); @@ -286,8 +261,22 @@ export class Cabidela { this.throw(`No schema for path '${pathToString(needle.path)}'`, needle); } + const { metadata, resolvedObject } = resolvePayload(needle.path, needle.payload); + if ( + resolvedObject !== undefined && + Object.hasOwn(needle.schema, "type") && + !metadata.types.has(needle.schema.type) + ) { + this.throw( + `Type mismatch of '${pathToString(needle.path)}', '${needle.schema.type}' not in ${Array.from(metadata.types) + .map((e) => `'${e}'`) + .join(",")}`, + needle, + ); + } + // https://json-schema.org/understanding-json-schema/reference/combining#not - if (needle.schema.hasOwnProperty("not")) { + if (resolvedObject !== undefined && Object.hasOwn(needle.schema, "not")) { let pass = false; try { this.parseSubSchema({ @@ -303,48 +292,32 @@ export class Cabidela { } // To validate against oneOf, the given data must be valid against exactly one of the given subschemas. - if (needle.schema.hasOwnProperty("oneOf")) { + if (resolvedObject !== undefined && Object.hasOwn(needle.schema, "oneOf")) { const rounds = this.parseList(needle.schema.oneOf, needle, (r: number) => r !== 1); if (rounds !== 1) { - if (needle.path.length == 0) { - this.throw(`oneOf at '${pathToString(needle.path)}' not met, ${rounds} matches`, needle); - } - return 0; + this.throw(`oneOf at '${pathToString(needle.path)}' not met, ${rounds} matches found`, needle); } return 1; } // To validate against anyOf, the given data must be valid against any (one or more) of the given subschemas. - if (needle.schema.hasOwnProperty("anyOf")) { + if (resolvedObject !== undefined && Object.hasOwn(needle.schema, "anyOf")) { if (this.parseList(needle.schema.anyOf, needle, (r: number) => r !== 0) === 0) { - if (needle.path.length == 0) { - this.throw(`anyOf at '${pathToString(needle.path)}' not met`, needle); - } - return 0; + this.throw(`anyOf at '${pathToString(needle.path)}' not met`, needle); } return 1; } // To validate against allOf, the given data must be valid against all of the given subschemas. - if (needle.schema.hasOwnProperty("allOf")) { + if (resolvedObject !== undefined && Object.hasOwn(needle.schema, "allOf")) { const conditions = needle.schema.allOf.reduce((r: any, c: any) => Object.assign(r, c), {}); - try { - this.parseSubSchema({ - ...needle, - schema: { type: needle.schema.type, ...conditions }, - carryProperties: true, - }); - } catch (e: any) { - if (needle.path.length == 0) { - throw e; - } - needle.errors.add(e.message as string); - return 0; - } + this.parseSubSchema({ + ...needle, + schema: { ...(needle.schema.type === undefined ? {} : { type: needle.schema.type }), ...conditions }, + carryProperties: true, + }); } - const { metadata, resolvedObject } = resolvePayload(needle.path, needle.payload); - // array, but object is not binary if (needle.schema.type === "array" && !metadata.types.has("binary") && !metadata.types.has("string")) { let matched = 0; @@ -360,7 +333,7 @@ export class Cabidela { return this.parseObject(needle) ? 1 : 0; } else if (resolvedObject !== undefined) { // This has to be before type checking - if (needle.schema.hasOwnProperty("const")) { + if (Object.hasOwn(needle.schema, "const")) { if (resolvedObject !== needle.schema.const) { this.throw( `const ${resolvedObject} doesn't match ${needle.schema.const} at '${pathToString(needle.path)}'`, @@ -373,7 +346,7 @@ export class Cabidela { } } // This has to be before type checking - if (needle.schema.hasOwnProperty("enum")) { + if (Object.hasOwn(needle.schema, "enum")) { if (Array.isArray(needle.schema.enum)) { if (!needle.schema.enum.includes(resolvedObject)) { this.throw( @@ -389,24 +362,15 @@ export class Cabidela { this.throw(`enum should be an array at '${pathToString(needle.path)}'`, needle); } } - // This has to be after handling enum - if (needle.schema.hasOwnProperty("type") && !metadata.types.has(needle.schema.type)) { - this.throw( - `Type mismatch of '${pathToString(needle.path)}', '${needle.schema.type}' not in ${Array.from(metadata.types) - .map((e) => `'${e}'`) - .join(",")}`, - needle, - ); - } /* If property === true, then it's declared validated no matter what the value is */ if (needle.schema !== true) { /* Otherwise check schema type */ switch (needle.schema.type) { case "string": - if (needle.schema.hasOwnProperty("maxLength") && metadata.size > needle.schema.maxLength) { + if (Object.hasOwn(needle.schema, "maxLength") && metadata.size > needle.schema.maxLength) { this.throw(`Length of '${pathToString(needle.path)}' must be <= ${needle.schema.maxLength}`, needle); } - if (needle.schema.hasOwnProperty("minLength") && metadata.size < needle.schema.minLength) { + if (Object.hasOwn(needle.schema, "minLength") && metadata.size < needle.schema.minLength) { this.throw( `Length of '${pathToString(needle.path)}' must be >= ${needle.schema.minLength} not met`, needle, @@ -415,25 +379,25 @@ export class Cabidela { break; case "number": case "integer": - if (needle.schema.hasOwnProperty("minimum") && resolvedObject < needle.schema.minimum) { + if (Object.hasOwn(needle.schema, "minimum") && resolvedObject < needle.schema.minimum) { this.throw(`'${pathToString(needle.path)}' must be >= ${needle.schema.minimum}`, needle); } - if (needle.schema.hasOwnProperty("exclusiveMinimum") && resolvedObject <= needle.schema.exclusiveMinimum) { + if (Object.hasOwn(needle.schema, "exclusiveMinimum") && resolvedObject <= needle.schema.exclusiveMinimum) { this.throw(`'${pathToString(needle.path)}' must be > ${needle.schema.exclusiveMinimum}`, needle); } - if (needle.schema.hasOwnProperty("maximum") && resolvedObject > needle.schema.maximum) { + if (Object.hasOwn(needle.schema, "maximum") && resolvedObject > needle.schema.maximum) { this.throw(`'${pathToString(needle.path)}' must be <= ${needle.schema.maximum}`, needle); } - if (needle.schema.hasOwnProperty("exclusiveMaximum") && resolvedObject >= needle.schema.exclusiveMaximum) { + if (Object.hasOwn(needle.schema, "exclusiveMaximum") && resolvedObject >= needle.schema.exclusiveMaximum) { this.throw(`'${pathToString(needle.path)}' must be < ${needle.schema.exclusiveMaximum}`, needle); } - if (needle.schema.hasOwnProperty("multipleOf") && resolvedObject % needle.schema.multipleOf !== 0) { + if (Object.hasOwn(needle.schema, "multipleOf") && resolvedObject % needle.schema.multipleOf !== 0) { this.throw(`'${pathToString(needle.path)}' must be multiple of ${needle.schema.multipleOf}`, needle); } break; } } - if (needle.schema.hasOwnProperty("pattern")) { + if (Object.hasOwn(needle.schema, "pattern")) { let passes = false; try { if (new RegExp(needle.schema.pattern).test(resolvedObject)) passes = true; @@ -447,7 +411,7 @@ export class Cabidela { return 1; } // Apply defaults - if (this.options.applyDefaults === true && needle.schema.hasOwnProperty("default")) { + if (this.options.applyDefaults === true && Object.hasOwn(needle.schema, "default")) { const applyDefaults = () => { needle.path.reduce(function (prev, curr, index) { // create objects as needed along the path, if they don't exist, so we can apply defaults at the end diff --git a/src/json-patch.ts b/src/json-patch.ts new file mode 100644 index 0000000..41bda8a --- /dev/null +++ b/src/json-patch.ts @@ -0,0 +1,145 @@ +const setObjectProperty = (object: any, property: string, value: any) => + Object.defineProperty(object, property, { value, writable: true, enumerable: true, configurable: true }); + +type JsonPatchOperation = { + op: "add" | "remove" | "replace" | "move" | "copy" | "test"; + path: string; + from?: string; + value?: any; +}; + +export const parseJsonPointer = (pointer: string): string[] => { + if (pointer === "") return []; + if (!pointer.startsWith("/")) throw new Error(`Invalid JSON Pointer '${pointer}'`); + if (/~(?:[^01]|$)/.test(pointer)) throw new Error(`Invalid JSON Pointer '${pointer}'`); + return pointer + .slice(1) + .split("/") + .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")); +}; + +const arrayIndex = (token: string, length: number, allowEnd: boolean): number => { + if (!/^(0|[1-9][0-9]*)$/.test(token)) throw new Error(`Invalid array index '${token}'`); + const index = Number(token); + if (index > length || (!allowEnd && index === length)) throw new Error(`Array index '${token}' is out of bounds`); + return index; +}; + +const getJsonPointer = (document: any, pointer: string): any => { + let value = document; + for (const token of parseJsonPointer(pointer)) { + if (Array.isArray(value)) { + value = value[arrayIndex(token, value.length, false)]; + } else if (value !== null && typeof value === "object" && Object.hasOwn(value, token)) { + value = value[token]; + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + } + return value; +}; + +const getJsonPointerParent = (document: any, pointer: string) => { + const path = parseJsonPointer(pointer); + if (path.length === 0) return { parent: undefined, token: undefined }; + const token = path.pop() as string; + const parentPointer = + path.length === 0 ? "" : `/${path.map((part) => part.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`; + return { parent: getJsonPointer(document, parentPointer), token }; +}; + +const addJsonPointer = (document: any, pointer: string, value: any): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return value; + if (Array.isArray(parent)) { + if (token === "-") { + parent.push(value); + } else { + parent.splice(arrayIndex(token, parent.length, true), 0, value); + } + } else if (parent !== null && typeof parent === "object") { + setObjectProperty(parent, token, value); + } else { + throw new Error(`JSON Pointer '${pointer}' parent is not a container`); + } + return document; +}; + +const removeJsonPointer = (document: any, pointer: string): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return undefined; + if (Array.isArray(parent)) { + parent.splice(arrayIndex(token, parent.length, false), 1); + } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { + delete parent[token]; + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + return document; +}; + +const replaceJsonPointer = (document: any, pointer: string, value: any): any => { + const { parent, token } = getJsonPointerParent(document, pointer); + if (token === undefined) return value; + if (Array.isArray(parent)) { + parent[arrayIndex(token, parent.length, false)] = value; + } else if (parent !== null && typeof parent === "object" && Object.hasOwn(parent, token)) { + setObjectProperty(parent, token, value); + } else { + throw new Error(`JSON Pointer '${pointer}' does not exist`); + } + return document; +}; + +const jsonEquals = (left: any, right: any): boolean => { + if (left === right) return true; + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false; + if (Array.isArray(left) !== Array.isArray(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key])) + ); +}; + +export const applyJsonPatch = (source: any, operations: JsonPatchOperation[]): any => { + if (!Array.isArray(operations)) throw new Error("$patch 'with' must be an array"); + let document = structuredClone(source); + for (const operation of operations) { + if (!operation || typeof operation.path !== "string") throw new Error("Invalid JSON patch operation"); + switch (operation.op) { + case "add": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch add operation requires 'value'"); + document = addJsonPointer(document, operation.path, structuredClone(operation.value)); + break; + case "remove": + document = removeJsonPointer(document, operation.path); + break; + case "replace": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch replace operation requires 'value'"); + document = replaceJsonPointer(document, operation.path, structuredClone(operation.value)); + break; + case "move": { + if (typeof operation.from !== "string") throw new Error("JSON patch move operation requires 'from'"); + const value = getJsonPointer(document, operation.from); + document = removeJsonPointer(document, operation.from); + document = addJsonPointer(document, operation.path, value); + break; + } + case "copy": + if (typeof operation.from !== "string") throw new Error("JSON patch copy operation requires 'from'"); + document = addJsonPointer(document, operation.path, structuredClone(getJsonPointer(document, operation.from))); + break; + case "test": + if (!Object.hasOwn(operation, "value")) throw new Error("JSON patch test operation requires 'value'"); + if (!jsonEquals(getJsonPointer(document, operation.path), operation.value)) { + throw new Error(`JSON patch test failed at '${operation.path}'`); + } + break; + default: + throw new Error(`Unsupported JSON patch operation '${operation.op}'`); + } + } + return document; +}; diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..610c3cf --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,191 @@ +import { applyJsonPatch, parseJsonPointer } from "./json-patch"; +import type { CabidelaOptions } from "."; + +// https://json-schema.org/understanding-json-schema/structuring#dollarref +export const parse$ref = (ref: string) => { + if (typeof ref !== "string") throw new Error("$ref must be a string"); + const [id, fragment = ""] = ref.split("#"); + return { + $id: id, + $path: parseJsonPointer(decodeURIComponent(fragment)), + }; +}; + +const isObject = (value: any) => value !== null && typeof value === "object" && !Array.isArray(value); + +function deepMerge(target: any, source: any): any { + if (Array.isArray(target) && Array.isArray(source)) return structuredClone([...target, ...source]); + if (!isObject(target) || !isObject(source)) return structuredClone(source); + const result = structuredClone(target); + for (const key of Object.keys(source)) { + setObjectProperty( + result, + key, + Object.hasOwn(target, key) ? deepMerge(target[key], source[key]) : structuredClone(source[key]), + ); + } + return result; +} + +const setObjectProperty = (object: any, property: string, value: any) => + Object.defineProperty(object, property, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + +// Preparation returns unchanged branches by identity. Check every root change +// before writing so a read-only property cannot leave a partially updated schema. +export const replaceObject = (target: any, source: any) => { + if (target === source) return target; + const removed = Object.keys(target).filter((key) => !Object.hasOwn(source, key)); + const changed = Object.keys(source).filter((key) => { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + return !descriptor || !("value" in descriptor) || !Object.is(descriptor.value, source[key]); + }); + for (const key of [...removed, ...changed]) { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor ? !descriptor.configurable : !Object.isExtensible(target)) { + throw new Error(`Cannot prepare schema: property '${key}' is not configurable or the schema is not extensible`); + } + } + for (const key of removed) delete target[key]; + for (const key of changed) setObjectProperty(target, key, source[key]); + return target; +}; + +const schemaMaps = new Set([ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", +]); +const schemaLists = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); +const schemaValues = new Set([ + "items", + "additionalItems", + "contains", + "unevaluatedItems", + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "not", + "if", + "then", + "else", + "contentSchema", +]); +// Cabidela also allows references in keyword values, e.g. maxLength: { $ref: ... }. +// These values are data after dereferencing, not recursively interpreted schemas. +const referenceValues = new Set([ + "type", + "enum", + "required", + "dependentRequired", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minContains", + "maxContains", + "minProperties", + "maxProperties", +]); + +// Resolve only schema positions. Literal defaults, enum members, annotations and +// JSON Patch operations remain data. Copy changed branches; never mutate input. +export const resolveSchema = (options: CabidelaOptions, definitions: any, schema: any): any => { + const active = new Set(); + const reference = (ref: string) => { + const { $id, $path } = parse$ref(ref); + let value = Object.hasOwn(definitions, $id) ? definitions[$id] : undefined; + for (const token of $path) { + value = value != null && Object.hasOwn(value, token) ? value[token] : undefined; + } + if (value === undefined) throw new Error(`Could not resolve '${ref}' $ref`); + return value; + }; + const mapValues = (object: any, resolve: (value: any) => any): any => { + if (object === null || typeof object !== "object") return object; + let result = object; + for (const key of Object.keys(object)) { + const value = resolve(object[key]); + if (value !== object[key]) { + if (result === object) result = Array.isArray(object) ? [...object] : { ...object }; + setObjectProperty(result, key, value); + } + } + return result; + }; + const resolve = (node: any, schemaMap = false): any => { + if (!isObject(node)) return node; + if (active.has(node)) throw new Error("Circular schema reference"); + active.add(node); + try { + let result = node; + for (const key of ["$ref", "$merge", "$patch"]) { + if (!Object.hasOwn(result, key)) continue; + // Legacy schemas can wrap a whole properties map in an extension. + // A property named "$patch" whose value is a schema is still a property. + if ( + schemaMap && + (key === "$ref" + ? typeof result[key] !== "string" + : !isObject(result[key]) || !Object.hasOwn(result[key], "source") || !Object.hasOwn(result[key], "with")) + ) + continue; + if (key === "$merge" && !options.useMerge) continue; + if (key === "$patch" && !options.usePatch) continue; + let expanded; + if (key === "$ref") { + expanded = resolve(reference(result[key]), schemaMap); + } else { + const extension = result[key]; + if (!isObject(extension)) throw new Error(`${key} must be an object`); + const source = resolve(extension.source, schemaMap); + expanded = + key === "$patch" + ? applyJsonPatch(source, extension.with) + : deepMerge(source, resolve(extension.with, schemaMap)); + expanded = resolve(expanded, schemaMap); + if (key === "$patch" && !isObject(expanded)) throw new Error("$patch result must be an object schema"); + } + if (!isObject(expanded)) return expanded; + result = { ...result, ...expanded }; + delete result[key]; + } + return schemaMap ? mapValues(result, resolve) : resolveChildren(result); + } finally { + active.delete(node); + } + }; + const resolveChildren = (node: any) => { + let result = node; + for (const key of Object.keys(node)) { + const value = node[key]; + let resolved = value; + if (schemaMaps.has(key)) resolved = resolve(value, true); + else if (schemaLists.has(key) && Array.isArray(value)) resolved = mapValues(value, resolve); + else if (schemaValues.has(key)) resolved = Array.isArray(value) ? mapValues(value, resolve) : resolve(value); + else if (referenceValues.has(key) && isObject(value) && Object.hasOwn(value, "$ref")) + resolved = reference(value.$ref); + if (resolved !== value) { + if (result === node) result = { ...node }; + setObjectProperty(result, key, resolved); + } + } + return result; + }; + return resolve(schema); +}; diff --git a/tests/01-schema-lifecycle.test.ts b/tests/01-schema-lifecycle.test.ts new file mode 100644 index 0000000..71eafee --- /dev/null +++ b/tests/01-schema-lifecycle.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { Cabidela } from "../src"; + +const input = () => ({ $id: "https://example.com/input", type: "string" }); + +describe("schema preparation", () => { + test("addSchema resolves references without enabling extensions", () => { + const c = new Cabidela({ $ref: "input#" }); + c.addSchema(input()); + expect(c.validate("valid")).toBe(true); + expect(() => c.validate(42)).toThrow(); + c.setSchema({ $ref: "input" }); + expect(() => c.validate(42)).toThrow(); + }); + + test("addSchema can defer resolution until the next preparation", () => { + const schema = { $ref: "input#" }; + const c = new Cabidela(schema); + c.addSchema(input(), false); + expect(c.getSchema()).toEqual({ $ref: "input#" }); + c.setOptions({}); + expect(c.getSchema()).toBe(schema); + expect(() => c.validate(42)).toThrow(); + }); + + test("resolves local definitions without extension flags", () => { + const c = new Cabidela({ $defs: { input: { type: "string" } }, $ref: "$defs#/input" }); + expect(() => c.validate(42)).toThrow(); + c.setSchema({ $defs: { input: { type: "number" } }, $ref: "$defs#/input" }); + expect(c.validate(42)).toBe(true); + }); + + test.each([{}, { usePatch: true }, { useMerge: true }])("preserves frozen, unchanged schemas with %j", (options) => { + const schema = Object.freeze({ type: "string", title: "Example" }); + const c = new Cabidela(schema, options); + c.setOptions({ fullErrors: false }); + c.setSchema(schema); + expect(c.getSchema()).toBe(schema); + expect(() => c.validate(42)).toThrow(); + }); + + test("preserves unchanged descriptors when another field changes", () => { + const schema = { title: "Example", $patch: { source: { type: "string" }, with: [] } }; + Object.defineProperty(schema, "title", { configurable: false, writable: false }); + const c = new Cabidela(schema, { usePatch: true }); + expect(c.getSchema()).toBe(schema); + expect(Object.getOwnPropertyDescriptor(schema, "title")).toMatchObject({ configurable: false, writable: false }); + expect(() => c.validate(42)).toThrow(); + }); + + test.each(["constructor", "setSchema", "setOptions"])("%s preflights all writes before mutating", (operation) => { + const schema = { + type: "string", + $patch: { source: { type: "number" }, with: [] }, + }; + Object.defineProperty(schema, "type", { configurable: false }); + const before = Object.getOwnPropertyDescriptors(schema); + const c = new Cabidela(operation === "setOptions" ? schema : { type: "string" }); + const active = c.getSchema(); + if (operation === "setSchema") c.setOptions({ usePatch: true }); + const update = () => { + if (operation === "constructor") new Cabidela(schema, { usePatch: true }); + else if (operation === "setSchema") c.setSchema(schema); + else c.setOptions({ usePatch: true }); + }; + expect(update).toThrowError("Cannot prepare schema"); + expect(Object.getOwnPropertyDescriptors(schema)).toEqual(before); + expect(c.getSchema()).toBe(active); + expect(() => c.validate(42)).toThrow(); + if (operation === "setOptions") { + // A failed update must not retain usePatch: true. + expect(() => c.setOptions({ fullErrors: false })).not.toThrow(); + } + }); + + test("does not delete an extension before discovering a non-extensible root", () => { + const schema = Object.preventExtensions({ $patch: { source: { type: "string" }, with: [] } }); + const before = structuredClone(schema); + expect(() => new Cabidela(schema, { usePatch: true })).toThrowError("Cannot prepare schema"); + expect(schema).toEqual(before); + }); + + test("a failed addSchema commit does not retain the registration", () => { + const schema = { $ref: "input#", type: "string" }; + Object.defineProperty(schema, "type", { configurable: false }); + const c = new Cabidela(schema); + expect(() => c.addSchema({ ...input(), type: "number" })).toThrowError("Cannot prepare schema"); + expect(c.getSchema()).toEqual({ $ref: "input#", type: "string" }); + expect(() => c.setOptions({ usePatch: true })).toThrowError("Could not resolve 'input#' $ref"); + expect(() => c.validate(42)).toThrow(); + }); + + test("references use own definition names and decode JSON Pointer tokens", () => { + const c = new Cabidela( + { $ref: "__proto__#/a~1b~0c" }, + { + subSchemas: [{ $id: "https://example.com/__proto__", "a/b~c": { type: "string" } }], + }, + ); + expect(() => c.validate(42)).toThrow(); + expect(() => new Cabidela({ $ref: "toString#" }, { usePatch: true })).toThrowError("Could not resolve"); + }); + + test("rejects circular references without mutating the supplied schema", () => { + const schema = { $defs: { a: { $ref: "$defs#/b" }, b: { $ref: "$defs#/a" } }, $ref: "$defs#/a" }; + const before = structuredClone(schema); + expect(() => new Cabidela(schema)).toThrowError("Circular schema reference"); + expect(schema).toEqual(before); + }); +}); diff --git a/tests/09-merge.test.ts b/tests/09-merge.test.ts index d84ce8c..7a37dac 100644 --- a/tests/09-merge.test.ts +++ b/tests/09-merge.test.ts @@ -1,8 +1,14 @@ import { expect, describe, test } from "vitest"; -import { Cabidela } from "../src"; import { FakeCabidela } from "./lib/fake-cabidela"; +import { Cabidela } from "../src"; describe("$merge", () => { + test("preserves literal null values and concatenates whole array elements", () => { + const source = { type: "object", default: null, examples: [{ x: 1 }] }; + const c = new Cabidela({ $merge: { source, with: { default: null, examples: [{ x: 2 }] } } }, { useMerge: true }); + expect(c.getSchema()).toEqual({ type: "object", default: null, examples: [{ x: 1 }, { x: 2 }] }); + expect(source.examples).toEqual([{ x: 1 }]); + }); test.skipIf(process.env.AJV)("two objects", () => { let schema = { $merge: { @@ -75,178 +81,3 @@ describe("$merge", () => { }); }); }); - -describe("$patch", () => { - test.skipIf(process.env.AJV)("applies JSON Patch operations", () => { - let schema = { - $patch: { - source: { - type: "object", - properties: { - effort: { type: "string", enum: ["low", "medium", "high"] }, - obsolete: { type: "boolean" }, - }, - required: ["effort"], - }, - with: [ - { op: "replace", path: "/properties/effort/enum", value: ["low", "medium", "high", "max", null] }, - { op: "add", path: "/properties/effort/default", value: "max" }, - { op: "remove", path: "/properties/obsolete" }, - { op: "copy", from: "/properties/effort", path: "/properties/copied_effort" }, - { op: "move", from: "/required/0", path: "/required/0" }, - { op: "test", path: "/properties/effort/default", value: "max" }, - ], - }, - }; - const cabidela = new FakeCabidela(schema, { usePatch: true }); - schema = cabidela.getSchema(); - expect(schema).toStrictEqual({ - type: "object", - properties: { - effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, - copied_effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, - }, - required: ["effort"], - }); - }); - - test.skipIf(process.env.AJV)("resolves references before applying a patch", () => { - let schema = { - $patch: { - source: { $ref: "$defs#/input" }, - with: [{ op: "replace", path: "/properties/effort/enum", value: ["low", "high"] }], - }, - $defs: { - input: { - type: "object", - properties: { effort: { type: "string", enum: ["low", "medium", "high"] } }, - }, - }, - }; - const cabidela = new FakeCabidela(schema, { usePatch: true }); - schema = cabidela.getSchema(); - expect(schema).toStrictEqual({ - type: "object", - properties: { effort: { type: "string", enum: ["low", "high"] } }, - }); - }); - - test.skipIf(process.env.AJV)("rejects a failed test operation", () => { - const schema = { - $patch: { - source: { type: "string" }, - with: [{ op: "test", path: "/type", value: "number" }], - }, - }; - expect(() => new FakeCabidela(schema, { usePatch: true })).toThrowError("JSON patch test failed at '/type'"); - }); - - test.skipIf(process.env.AJV).each([ - { $patch: { with: [] } }, - { $patch: { source: { type: "string" }, with: [{ op: "remove", path: "" }] } }, - { $patch: { source: { type: "string" }, with: [{ op: "replace", path: "", value: false }] } }, - ])("rejects a patch that does not produce an object schema", (schema) => { - expect(() => new FakeCabidela(schema, { usePatch: true })).toThrowError( - "$patch result must be an object schema", - ); - }); - - test("resolves patches installed through setSchema", () => { - const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); - - cabidela.setSchema({ - $patch: { - source: { type: "string", enum: ["low", "high"] }, - with: [{ op: "add", path: "/enum/-", value: "max" }], - }, - }); - - expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); - expect(() => cabidela.validate("max")).not.toThrow(); - }); - - test("resolves existing patches when setOptions enables them", () => { - const cabidela = new Cabidela({ - $patch: { - source: { type: "string", enum: ["low", "high"] }, - with: [{ op: "add", path: "/enum/-", value: "max" }], - }, - }); - - cabidela.setOptions({ usePatch: true }); - - expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); - expect(() => cabidela.validate("max")).not.toThrow(); - }); - - test("retains the active schema when setSchema preparation fails", () => { - const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); - - expect(() => - cabidela.setSchema({ - $patch: { - source: { type: "object" }, - with: [{ op: "remove", path: "/missing" }], - }, - }), - ).toThrowError("JSON Pointer '/missing' does not exist"); - - expect(cabidela.getSchema()).toStrictEqual({ type: "string" }); - expect(() => cabidela.validate(42)).toThrow(); - }); - - test("does not retain an invalid added schema", () => { - const cabidela = new Cabidela({ type: "string" }); - - expect(() => cabidela.addSchema({ type: "object" })).toThrowError("subSchemas need $id"); - expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); - expect(() => cabidela.validate(42)).not.toThrow(); - }); - - test("does not retain invalid options", () => { - const cabidela = new Cabidela({ type: "string" }); - - expect(() => cabidela.setOptions({ subSchemas: [{ type: "object" }] })).toThrowError( - "subSchemas need $id", - ); - expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); - expect(() => cabidela.validate(42)).not.toThrow(); - }); - - test.each(["__proto__", "constructor", "prototype"])( - "adds %s as an own JSON Pointer member", - (property) => { - const cabidela = new Cabidela( - { - $patch: { - source: { type: "object", properties: {} }, - with: [{ op: "add", path: `/properties/${property}`, value: { type: "string" } }], - }, - }, - { usePatch: true }, - ); - const properties = cabidela.getSchema().properties; - - expect(Object.hasOwn(properties, property)).toBe(true); - expect(properties[property]).toStrictEqual({ type: "string" }); - expect(Object.getPrototypeOf(properties)).toBe(Object.prototype); - }, - ); - - test.each(["__proto__", "constructor", "prototype"])("adds %s as an own root member", (property) => { - const cabidela = new Cabidela( - { - $patch: { - source: {}, - with: [{ op: "add", path: `/${property}`, value: { type: "string" } }], - }, - }, - { usePatch: true }, - ); - const schema = cabidela.getSchema(); - - expect(Object.hasOwn(schema, property)).toBe(true); - expect(schema[property]).toStrictEqual({ type: "string" }); - expect(Object.getPrototypeOf(schema)).toBe(Object.prototype); - }); -}); diff --git a/tests/11-patch.test.ts b/tests/11-patch.test.ts new file mode 100644 index 0000000..fbb2a37 --- /dev/null +++ b/tests/11-patch.test.ts @@ -0,0 +1,298 @@ +import { expect, describe, test } from "vitest"; +import { Cabidela } from "../src"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +describe("$patch", () => { + test("is opt-in and preserves the supplied schema until enabled", () => { + const schema = { $patch: { source: { type: "string" }, with: [] } }; + const c = new Cabidela(schema); + expect(c.getSchema()).toBe(schema); + expect(schema).toHaveProperty("$patch"); + c.setOptions({ usePatch: true }); + expect(c.getSchema()).toBe(schema); + expect(() => c.validate(42)).toThrow(); + }); + + test("narrows an enum from a local reference (README example)", () => { + const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); + const example = readme.split("## $patch")[1].match(/const schema = ([\s\S]*?);/); + expect(example).not.toBeNull(); + const schema = runInNewContext(`(${example![1]})`); + const c = new Cabidela(schema, { usePatch: true }); + expect(c.validate({ reasoning_effort: "high" })).toBe(true); + expect(() => c.validate({ reasoning_effort: "medium" })).toThrowError("enum"); + }); + + test("preserves literal values and ignores extra operation members", () => { + const literal = { $ref: "literal", $patch: { type: "string" } }; + const c = new Cabidela( + { + $patch: { + source: { type: "object", default: literal, examples: [literal], "x-annotation": literal }, + with: [ + { op: "test", path: "/default", value: literal }, + { op: "add", path: "/const", value: literal, ignored: { $ref: "not-a-reference" } }, + { op: "add", path: "/enum", value: [literal] }, + ], + }, + }, + { usePatch: true }, + ); + expect(c.getSchema()).toEqual({ + type: "object", + default: literal, + examples: [literal], + "x-annotation": literal, + const: literal, + enum: [literal], + }); + }); + + test.each(["$patch", "$merge", "$ref", "constructor", "__proto__", "hasOwnProperty"])( + "preserves the property name %s", + (name) => { + const c = new Cabidela( + { + $patch: { + source: { type: "object", properties: { [name]: { type: "string" } }, required: [name] }, + with: [{ op: "add", path: `/properties/${name}/maxLength`, value: 3 }], + }, + }, + { usePatch: true, useMerge: true }, + ); + expect(c.validate({ [name]: "yes" })).toBe(true); + expect(() => c.validate({ [name]: "too long" })).toThrow(); + expect(Object.hasOwn(c.getSchema().properties, name)).toBe(true); + expect(Object.getPrototypeOf(c.getSchema().properties)).toBe(Object.prototype); + }, + ); + + test("resolves schemas inserted by patch operations, after applying the operations", () => { + const c = new Cabidela( + { + $defs: { name: { type: "string" } }, + $patch: { + source: { type: "object", properties: {} }, + with: [ + { op: "add", path: "/properties/name", value: { $ref: "$defs#/name" } }, + { op: "test", path: "/properties/name/$ref", value: "$defs#/name" }, + ], + }, + }, + { usePatch: true }, + ); + expect(c.validate({ name: "valid" })).toBe(true); + expect(() => c.validate({ name: 42 })).toThrow(); + }); + + test("combines nested merge and patch extensions without modifying definitions", () => { + const base = { $id: "https://example.com/input", type: "string", enum: ["low", "medium", "high"] }; + const c = new Cabidela( + { + type: "object", + properties: { + effort: { + $patch: { + source: { $merge: { source: { $ref: "input" }, with: { default: "high" } } }, + with: [{ op: "remove", path: "/enum/1" }], + }, + }, + original: { $ref: "input" }, + }, + }, + { usePatch: true, useMerge: true, subSchemas: [base] }, + ); + expect(c.validate({ effort: "high", original: "medium" })).toBe(true); + expect(() => c.validate({ effort: "medium" })).toThrow(); + expect(base.enum).toEqual(["low", "medium", "high"]); + }); + + test("supports a patch wrapping an entire properties map", () => { + const c = new Cabidela( + { + type: "object", + properties: { + $patch: { + source: { p: { type: "string" }, q: { type: "number" } }, + with: [{ op: "remove", path: "/q" }], + }, + }, + additionalProperties: false, + }, + { usePatch: true }, + ); + expect(c.validate({ p: "valid" })).toBe(true); + expect(() => c.validate({ q: 42 })).toThrow(); + }); + + test("applies JSON Patch operations", () => { + let schema = { + $patch: { + source: { + type: "object", + properties: { + effort: { type: "string", enum: ["low", "medium", "high"] }, + obsolete: { type: "boolean" }, + }, + required: ["effort"], + }, + with: [ + { op: "replace", path: "/properties/effort/enum", value: ["low", "medium", "high", "max", null] }, + { op: "add", path: "/properties/effort/default", value: "max" }, + { op: "remove", path: "/properties/obsolete" }, + { op: "copy", from: "/properties/effort", path: "/properties/copied_effort" }, + { op: "move", from: "/required/0", path: "/required/0" }, + { op: "test", path: "/properties/effort/default", value: "max" }, + ], + }, + }; + const cabidela = new Cabidela(schema, { usePatch: true }); + schema = cabidela.getSchema(); + expect(schema).toStrictEqual({ + type: "object", + properties: { + effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, + copied_effort: { type: "string", enum: ["low", "medium", "high", "max", null], default: "max" }, + }, + required: ["effort"], + }); + }); + + test("resolves references before applying a patch", () => { + let schema = { + $patch: { + source: { $ref: "$defs#/input" }, + with: [{ op: "replace", path: "/properties/effort/enum", value: ["low", "high"] }], + }, + $defs: { + input: { + type: "object", + properties: { effort: { type: "string", enum: ["low", "medium", "high"] } }, + }, + }, + }; + const cabidela = new Cabidela(schema, { usePatch: true }); + schema = cabidela.getSchema(); + expect(schema).toStrictEqual({ + type: "object", + properties: { effort: { type: "string", enum: ["low", "high"] } }, + }); + }); + + test("rejects a failed test operation", () => { + const schema = { + $patch: { + source: { type: "string" }, + with: [{ op: "test", path: "/type", value: "number" }], + }, + }; + expect(() => new Cabidela(schema, { usePatch: true })).toThrowError("JSON patch test failed at '/type'"); + }); + + test.each([ + { $patch: { with: [] } }, + { $patch: { source: { type: "string" }, with: [{ op: "remove", path: "" }] } }, + { $patch: { source: { type: "string" }, with: [{ op: "replace", path: "", value: false }] } }, + { + $defs: { input: false }, + $patch: { source: {}, with: [{ op: "replace", path: "", value: { $ref: "$defs#/input" } }] }, + }, + ])("rejects a patch that does not produce an object schema", (schema) => { + expect(() => new Cabidela(schema, { usePatch: true })).toThrowError("$patch result must be an object schema"); + }); + + test("resolves patches installed through setSchema", () => { + const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); + + cabidela.setSchema({ + $patch: { + source: { type: "string", enum: ["low", "high"] }, + with: [{ op: "add", path: "/enum/-", value: "max" }], + }, + }); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); + expect(() => cabidela.validate("max")).not.toThrow(); + }); + + test("resolves existing patches when setOptions enables them", () => { + const cabidela = new Cabidela({ + $patch: { + source: { type: "string", enum: ["low", "high"] }, + with: [{ op: "add", path: "/enum/-", value: "max" }], + }, + }); + + cabidela.setOptions({ usePatch: true }); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string", enum: ["low", "high", "max"] }); + expect(() => cabidela.validate("max")).not.toThrow(); + }); + + test("retains the active schema when setSchema preparation fails", () => { + const cabidela = new Cabidela({ type: "string" }, { usePatch: true }); + + expect(() => + cabidela.setSchema({ + $patch: { + source: { type: "object" }, + with: [{ op: "remove", path: "/missing" }], + }, + }), + ).toThrowError("JSON Pointer '/missing' does not exist"); + + expect(cabidela.getSchema()).toStrictEqual({ type: "string" }); + expect(() => cabidela.validate(42)).toThrow(); + }); + + test("does not retain an invalid added schema", () => { + const cabidela = new Cabidela({ type: "string" }); + + expect(() => cabidela.addSchema({ type: "object" })).toThrowError("subSchemas need $id"); + expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); + expect(() => cabidela.validate(42)).not.toThrow(); + }); + + test("does not retain invalid options", () => { + const cabidela = new Cabidela({ type: "string" }); + + expect(() => cabidela.setOptions({ subSchemas: [{ type: "object" }] })).toThrowError("subSchemas need $id"); + expect(() => cabidela.setSchema({ type: "number" })).not.toThrow(); + expect(() => cabidela.validate(42)).not.toThrow(); + }); + + test.each(["__proto__", "constructor", "prototype"])("adds %s as an own JSON Pointer member", (property) => { + const cabidela = new Cabidela( + { + $patch: { + source: { type: "object", properties: {} }, + with: [{ op: "add", path: `/properties/${property}`, value: { type: "string" } }], + }, + }, + { usePatch: true }, + ); + const properties = cabidela.getSchema().properties; + + expect(Object.hasOwn(properties, property)).toBe(true); + expect(properties[property]).toStrictEqual({ type: "string" }); + expect(Object.getPrototypeOf(properties)).toBe(Object.prototype); + }); + + test.each(["__proto__", "constructor", "prototype"])("adds %s as an own root member", (property) => { + const cabidela = new Cabidela( + { + $patch: { + source: {}, + with: [{ op: "add", path: `/${property}`, value: { type: "string" } }], + }, + }, + { usePatch: true }, + ); + const schema = cabidela.getSchema(); + + expect(Object.hasOwn(schema, property)).toBe(true); + expect(schema[property]).toStrictEqual({ type: "string" }); + expect(Object.getPrototypeOf(schema)).toBe(Object.prototype); + }); +}); diff --git a/tests/12-json-patch.test.ts b/tests/12-json-patch.test.ts new file mode 100644 index 0000000..c189fd2 --- /dev/null +++ b/tests/12-json-patch.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "vitest"; +import { applyJsonPatch } from "../src/json-patch"; + +describe("JSON Patch operations", () => { + test("applies object edits in order", () => { + const source = { title: "draft", obsolete: true }; + expect( + applyJsonPatch(source, [ + { op: "add", path: "/title", value: "review" }, + { op: "copy", from: "/title", path: "/previous" }, + { op: "replace", path: "/title", value: "published" }, + { op: "move", from: "/previous", path: "/history" }, + { op: "remove", path: "/obsolete" }, + { op: "test", path: "/history", value: "review" }, + ]), + ).toEqual({ title: "published", history: "review" }); + expect(source).toEqual({ title: "draft", obsolete: true }); + }); + + test.each([ + [{ op: "add", path: "/0", value: "new" }, ["new", "red", "blue", "green"]], + [{ op: "add", path: "/1", value: "new" }, ["red", "new", "blue", "green"]], + [{ op: "add", path: "/3", value: "new" }, ["red", "blue", "green", "new"]], + [{ op: "add", path: "/-", value: "new" }, ["red", "blue", "green", "new"]], + [{ op: "remove", path: "/1" }, ["red", "green"]], + [{ op: "replace", path: "/1", value: "new" }, ["red", "new", "green"]], + [{ op: "move", from: "/0", path: "/2" }, ["blue", "green", "red"]], + [{ op: "move", from: "/2", path: "/0" }, ["green", "red", "blue"]], + [{ op: "move", from: "/1", path: "/1" }, ["red", "blue", "green"]], + [{ op: "copy", from: "/0", path: "/-" }, ["red", "blue", "green", "red"]], + ] as const)("edits arrays with %j", (operation, expected) => { + const source = ["red", "blue", "green"]; + expect(applyJsonPatch(source, [operation])).toEqual(expected); + expect(source).toEqual(["red", "blue", "green"]); + }); + + test.each(["add", "replace"] as const)("%s isolates inserted values and the source", (op) => { + const source = { settings: { enabled: false } }; + const value = { enabled: true }; + const result = applyJsonPatch(source, [{ op, path: "/settings", value }]); + expect(result).toEqual({ settings: { enabled: true } }); + result.settings.enabled = false; + expect(value).toEqual({ enabled: true }); + expect(source).toEqual({ settings: { enabled: false } }); + expect(result).not.toBe(source); + }); + + test("copies nested values independently", () => { + const source = { original: { tags: ["ready"] } }; + const result = applyJsonPatch(source, [ + { op: "copy", from: "/original", path: "/copy" }, + { op: "add", path: "/copy/tags/-", value: "reviewed" }, + ]); + expect(result).toEqual({ original: { tags: ["ready"] }, copy: { tags: ["ready", "reviewed"] } }); + result.original.tags.push("changed"); + expect(source).toEqual({ original: { tags: ["ready"] } }); + }); + + test("decodes escaped tokens once and addresses empty member names", () => { + expect( + applyJsonPatch({ "a/b": { "~key": { "": 1 } }, "~1": 2 }, [ + { op: "replace", path: "/a~1b/~0key/", value: 3 }, + { op: "move", from: "/~01", path: "/" }, + ]), + ).toEqual({ "a/b": { "~key": { "": 3 } }, "": 2 }); + }); + + test.each([[null], [false], [7], ["ready"], [[1, 2]], [{ "": "member" }]])( + "supports the JSON root value %j", + (value) => { + expect(applyJsonPatch(value, [{ op: "test", path: "", value }])).toEqual(value); + expect(applyJsonPatch({}, [{ op: "add", path: "", value }])).toEqual(value); + expect(applyJsonPatch({}, [{ op: "replace", path: "", value }])).toEqual(value); + expect(applyJsonPatch(value, [{ op: "remove", path: "" }])).toBeUndefined(); + }, + ); + + test.each(["copy", "move"] as const)("%s can make a member the root", (op) => { + expect(applyJsonPatch({ value: [1, 2] }, [{ op, from: "/value", path: "" }])).toEqual([1, 2]); + }); + + test("can copy the root into a member without creating a cycle", () => { + expect(applyJsonPatch({ value: 1 }, [{ op: "copy", from: "", path: "/snapshot" }])).toEqual({ + value: 1, + snapshot: { value: 1 }, + }); + }); + + test.each([ + [{ first: 1, second: [null, true] }, { second: [null, true], first: 1 }, true], + [[1, 2], [2, 1], false], + [[1], { "0": 1 }, false], + [{ nested: { value: 1 } }, { nested: { value: "1" } }, false], + [{ value: null }, {}, false], + ])("compares %j with %j structurally", (source, value, equal) => { + const apply = () => applyJsonPatch(source, [{ op: "test", path: "", value }]); + if (equal) expect(apply()).toEqual(source); + else expect(apply).toThrowError("test failed"); + }); + + test.each(["01", "-1", "1.5", "4", "word", ""])("rejects invalid array index %j", (index) => { + const source = ["red", "blue", "green"]; + expect(() => applyJsonPatch(source, [{ op: "add", path: `/${index}`, value: "new" }])).toThrow(); + expect(() => applyJsonPatch(source, [{ op: "remove", path: `/${index}` }])).toThrow(); + expect(() => applyJsonPatch(source, [{ op: "test", path: `/${index}`, value: "red" }])).toThrow(); + }); + + test.each(["/3", "/-"])("requires an existing array element at %s", (path) => { + const source = ["red", "blue", "green"]; + expect(() => applyJsonPatch(source, [{ op: "remove", path }])).toThrow(); + expect(() => applyJsonPatch(source, [{ op: "replace", path, value: "new" }])).toThrow(); + expect(() => applyJsonPatch(source, [{ op: "copy", from: path, path: "/0" }])).toThrow(); + expect(() => applyJsonPatch(source, [{ op: "move", from: path, path: "/0" }])).toThrow(); + }); + + test.each([ + { op: "add", path: "/missing/child", value: 1 }, + { op: "add", path: "/leaf/child", value: 1 }, + { op: "add", path: "/__proto__/child", value: 1 }, + { op: "remove", path: "/missing" }, + { op: "replace", path: "/missing", value: 1 }, + { op: "test", path: "/missing", value: null }, + { op: "copy", from: "/missing", path: "/copy" }, + { op: "move", from: "/missing", path: "/moved" }, + ] as const)("requires existing targets and container parents for %j", (operation) => { + expect(() => applyJsonPatch({ leaf: null }, [operation])).toThrow(); + }); + + test.each([null, {}, "invalid"])("rejects a non-array operation list %j", (operations) => { + expect(() => applyJsonPatch({}, operations as any)).toThrowError("must be an array"); + }); + + test.each([ + null, + {}, + { op: "splice", path: "" }, + { op: "remove" }, + { op: "remove", path: 0 }, + { op: "add", path: "" }, + { op: "replace", path: "" }, + { op: "test", path: "" }, + { op: "copy", path: "" }, + { op: "move", path: "" }, + { op: "copy", from: null, path: "" }, + { op: "move", from: 0, path: "" }, + ])("rejects malformed operation %j", (operation) => { + expect(() => applyJsonPatch({}, [operation as any])).toThrow(); + }); + + test.each(["__proto__", "constructor", "prototype", "hasOwnProperty"])("safely writes the member %s", (key) => { + const result = applyJsonPatch({}, [{ op: "add", path: `/${key}`, value: { safe: true } }]); + expect(Object.hasOwn(result, key)).toBe(true); + expect(result[key]).toEqual({ safe: true }); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.hasOwn(Object.prototype, "safe")).toBe(false); + }); + + test.each(["/~", "/~2", "relative"])("rejects invalid JSON Pointer %s", (path) => { + expect(() => applyJsonPatch({}, [{ op: "add", path, value: 1 }])).toThrowError("Invalid JSON Pointer"); + expect(() => applyJsonPatch({}, [{ op: "copy", from: path, path: "/copy" }])).toThrowError("Invalid JSON Pointer"); + }); + + test("rejects moving an ancestor into its descendant without mutating input", () => { + const source = { a: { b: {} } }; + expect(() => applyJsonPatch(source, [{ op: "move", from: "/a", path: "/a/b/c" }])).toThrow(); + expect(source).toEqual({ a: { b: {} } }); + }); + + test("rolls back the whole operation sequence on failure", () => { + const source = { enum: ["low", "medium", "high"] }; + expect(() => + applyJsonPatch(source, [ + { op: "remove", path: "/enum/1" }, + { op: "test", path: "/enum/0", value: "unexpected" }, + ]), + ).toThrowError("test failed"); + expect(source.enum).toEqual(["low", "medium", "high"]); + }); +}); diff --git a/tests/20-composition.test.ts b/tests/20-composition.test.ts index 7bb482c..df7e452 100644 --- a/tests/20-composition.test.ts +++ b/tests/20-composition.test.ts @@ -1,5 +1,64 @@ import { expect, test, describe, it } from "vitest"; import { FakeCabidela } from "./lib/fake-cabidela"; +import { Cabidela } from "../src"; + +describe("composition branch results", () => { + test.each(["anyOf", "oneOf"])("%s does not count a scalar as an object or array", (keyword) => { + const c = new Cabidela({ [keyword]: [{ type: "object" }, { type: "array", items: { type: "string" } }] }); + expect(c.validate({})).toBe(true); + expect(c.validate([])).toBe(true); + for (const value of [42, true, null, "text"]) expect(() => c.validate(value)).toThrow(); + }); + test.each([false, true])("leaves absent optional composed properties absent (defaults=%s)", (applyDefaults) => { + for (const keyword of ["anyOf", "oneOf", "allOf"]) { + const c = new Cabidela( + { + type: "object", + properties: { + x: { [keyword]: [{ type: "string" }, { type: "number" }] }, + y: { not: { type: "string" } }, + }, + }, + { applyDefaults }, + ); + const payload = {}; + expect(c.validate(payload)).toBe(true); + expect(payload).toEqual({}); + expect(() => c.validate({ x: true })).toThrow(); + } + }); + test.each([{ payload: [] }, { payload: [{ x: "a" }, { x: "b" }] }])( + "counts an array as one matching branch: %j", + ({ payload }) => { + const c = new Cabidela({ + oneOf: [{ type: "array", items: { type: "object", properties: { x: { type: "string" } }, required: ["x"] } }], + }); + expect(c.validate(payload)).toBe(true); + }, + ); + + test("counts a valid empty object as a matching branch", () => { + expect(new Cabidela({ oneOf: [{ type: "object" }] }).validate({})).toBe(true); + expect(() => new Cabidela({ oneOf: [{ type: "object" }, { type: "object" }] }).validate({})).toThrowError( + "2 matches found", + ); + }); + + test.each(["anyOf", "oneOf", "allOf"])("propagates a nested %s failure", (keyword) => { + const c = new Cabidela({ + type: "object", + properties: { x: { anyOf: [{ [keyword]: [{ type: "string" }] }] } }, + required: ["x"], + }); + expect(c.validate({ x: "valid" })).toBe(true); + expect(() => c.validate({ x: 42 })).toThrow(); + }); + + test.each(["anyOf", "oneOf", "allOf"])("reports invalid optional properties using %s", (keyword) => { + const c = new Cabidela({ type: "object", properties: { x: { [keyword]: [{ type: "string" }] } } }); + expect(() => c.validate({ x: 42 })).toThrow(); + }); +}); describe("allOf, two properties", () => { let schema = { diff --git a/tests/60-error-messages.test.ts b/tests/60-error-messages.test.ts index 42166b6..d0b3230 100644 --- a/tests/60-error-messages.test.ts +++ b/tests/60-error-messages.test.ts @@ -1,5 +1,11 @@ import { expect, test, describe, it } from "vitest"; import { FakeCabidela } from "./lib/fake-cabidela"; +import { Cabidela } from "../src"; + +test("maxProperties reports the configured upper bound", () => { + const c = new Cabidela({ type: "object", minProperties: 0, maxProperties: 1 }); + expect(() => c.validate({ a: 1, b: 2 })).toThrowError("maxProperties at '/' is 1, got 2"); +}); describe("errorMessages simple", () => { let schema = { @@ -89,13 +95,13 @@ describe("errorMessages oneOf", () => { cabidela.validate({ missing: "property", }), - ).toThrowError(/oneOf at '.' not met, 0 matches: prompt required, messages required/); + ).toThrowError(/oneOf at '.' not met, 0 matches found: prompt required, messages required/); }); test.skipIf(process.env.AJV)("messages need role and content", () => { expect(() => cabidela.validate({ messages: [{ role: "user" }], }), - ).toThrowError(/oneOf at '.' not met, 0 matches: prompt required, messages need both role and content/); + ).toThrowError(/oneOf at '.' not met, 0 matches found: prompt required, messages need both role and content/); }); }); diff --git a/tests/70-complex-examples.test.ts b/tests/70-complex-examples.test.ts index afee69f..334c4f1 100644 --- a/tests/70-complex-examples.test.ts +++ b/tests/70-complex-examples.test.ts @@ -1,5 +1,6 @@ import { expect, test, describe, it } from "vitest"; import { FakeCabidela } from "./lib/fake-cabidela"; +import { Cabidela } from "../src"; import { schemaBlocks } from "./lib/subschemas"; import fs from "fs"; @@ -459,7 +460,8 @@ describe("Ai, Combined Text Generation", () => { ], }; - const cabidela = new FakeCabidela(schema, { subSchemas: schemaBlocks, useMerge: true, applyDefaults: true }); + // This asserts Cabidela's materialized schema; AJV does not rewrite its input. + const cabidela = new Cabidela(schema, { subSchemas: schemaBlocks, useMerge: true, applyDefaults: true }); schema = cabidela.getSchema(); fs.writeFileSync("/tmp/schema.json", JSON.stringify(schema, null, 2)); expect(schema).toStrictEqual({