diff --git a/CHANGELOG.md b/CHANGELOG.md index eea56e0..4724cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.2.5] - 2025-03-27 + +### Changed + +- Improved error handling for oneOf, anyOf, allOf + ## [0.2.4] - 2025-03-24 ### Changed diff --git a/README.md b/README.md index ec56530..de8165d 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. @@ -299,6 +300,50 @@ new Cabidela(schema, { useMerge: true }); You can combine `$merge` with `$id` and `$ref` keywords, which get resolved first, for even more flexibility. +## $patch + +Use can use `$patch` to remove properties from an object. + +Here's how it works: + +```json +{ + "$patch": { + "source": { + "type": "object", + "properties": { + "p": { "type": "string" }, + "q": { "type": "number" } + }, + "additionalProperties": false + }, + "with": { + "properties": { "q": null } + } + } +} +``` + +Resolves to: + +```json +{ + "type": "object", + "properties": { + "p": { "type": "string" }, + }, + "additionalProperties": false +} +``` + +To use `$patch` set the `usePatch` flag to true when creating the instance. + +```js +new Cabidela(schema, { usePatch: true }); +``` + +Like `$merge`, you can combine `$patch` with `$id` and `$ref` keywords, which get resolved first, for even more flexibility. + ## 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/src/helpers.ts b/src/helpers.ts index 0b05883..2e7ac54 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -35,9 +35,26 @@ function deepMerge(target: any, source: any) { return result; } +function deepPatch(target: any, source: any) { + const result = { ...target }; + for (const key of Object.keys(target)) { + if (typeof target[key] == "object" && typeof source[key] == "object") { + const patch = deepPatch(target[key], source[key]); + if (patch) result[key] = patch; + else delete result[key]; + } else if (source === null) { + return null; + } else { + result[key] = structuredClone(result[key]); + } + } + return result; +} + export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: any) => { const ts = (obj: any, cb?: any) => { let hits: number; + if (!obj) return; do { hits = 0; for (const key of Object.keys(obj)) { @@ -57,6 +74,16 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: delete obj[key]; } } + if (options.usePatch && key == "$patch") { + const merge = deepPatch(obj[key].source, obj[key].with); + if (cb) { + cb(merge); + } else { + // root level + Object.assign(obj, merge); + delete obj[key]; + } + } } else { if (key == "$ref") { const { $id, $path } = parse$ref(obj[key]); @@ -76,7 +103,7 @@ export const traverseSchema = (options: CabidelaOptions, definitions: any, obj: } } } - } while (hits > 0); + } while (obj && hits > 0); }; ts(obj); }; diff --git a/src/index.ts b/src/index.ts index 58f67e3..6ae9a4d 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; @@ -30,6 +31,8 @@ export class Cabidela { this.options = { fullErrors: true, subSchemas: [], + useMerge: false, + usePatch: false, applyDefaults: false, errorMessages: false, ...(options || {}), @@ -43,7 +46,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); } } @@ -139,7 +142,7 @@ export class Cabidela { if (needle.schema.hasOwnProperty("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 +210,7 @@ export class Cabidela { absorvErrors: true, deferredApplyDefaults: true, }); - rounds += matches; + rounds++; if (breakCondition && breakCondition(rounds)) break; defaultsCallbacks.push(...needle.defaultsCallbacks); needle.defaultsCallbacks = []; @@ -247,9 +250,7 @@ export class Cabidela { if (needle.schema.hasOwnProperty("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); - } + this.throw(`oneOf at '${pathToString(needle.path)}' not met, ${rounds} matches found`, needle); return 0; } return 1; diff --git a/tests/11-patch.test.ts b/tests/11-patch.test.ts new file mode 100644 index 0000000..067e2dc --- /dev/null +++ b/tests/11-patch.test.ts @@ -0,0 +1,31 @@ +import { expect, describe, test } from "vitest"; +import { FakeCabidela } from "./lib/fake-cabidela"; + +describe("$patch", () => { + test.skipIf(process.env.AJV)("two objects", () => { + let schema = { + $patch: { + source: { + type: "object", + properties: { + p: { type: "string" }, + q: { type: "number" }, + }, + additionalProperties: false, + }, + with: { + properties: { q: null }, + }, + }, + }; + const cabidela = new FakeCabidela(schema, { usePatch: true }); + schema = cabidela.getSchema(); + expect(schema).toStrictEqual({ + type: "object", + properties: { + p: { type: "string" }, + }, + additionalProperties: false, + }); + }); +}); diff --git a/tests/60-error-messages.test.ts b/tests/60-error-messages.test.ts index 42166b6..17ea347 100644 --- a/tests/60-error-messages.test.ts +++ b/tests/60-error-messages.test.ts @@ -89,13 +89,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/); }); });