From 635c831261ec9df84bc4af072a5ca89d7e9378ba Mon Sep 17 00:00:00 2001 From: belfordzjeeves Date: Sat, 17 Oct 2026 05:23:57 +0000 Subject: [PATCH] feat: support oneOf validateItem now counts branches matching the data; exactly one match validates, otherwise a OneOfValidationError is reported. The validator's traverse walk skips subschemas beneath oneOf keywords (they are handled recursively at the oneOf schema) so branches are no longer checked against the parent schema's data. --- src/base-validators/oneOf.ts | 13 ++++++ src/index.test.ts | 80 ++++++++++++++++++++++++++++++++++++ src/index.ts | 27 +++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/base-validators/oneOf.ts diff --git a/src/base-validators/oneOf.ts b/src/base-validators/oneOf.ts new file mode 100644 index 0000000..23fe12d --- /dev/null +++ b/src/base-validators/oneOf.ts @@ -0,0 +1,13 @@ +import { JSONSchemaObject } from "@json-schema-tools/meta-schema"; + +export class OneOfValidationError implements Error { + public name = "OneOfValidationError"; + public message: string; + + constructor(schema: JSONSchemaObject, data: any, reason: string) { + this.message = [ + "invalid oneOf", + `reason: ${reason}`, + ].join("\n"); + } +} diff --git a/src/index.test.ts b/src/index.test.ts index fe781bc..df08f9b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,6 +2,8 @@ import validator, { ValidationErrors } from "./"; import { JSONSchema } from "@json-schema-tools/meta-schema"; import { ObjectValidationError } from "./base-validators/object"; import { NumberValidationError } from "./base-validators/number"; +import { StringValidationError } from "./base-validators/string"; +import { OneOfValidationError } from "./base-validators/oneOf"; describe("validator", () => { it("is a function", () => { @@ -180,4 +182,82 @@ describe("validator", () => { expect(result).toBe(true); }); }); + + describe("oneOf", () => { + it("validates when data matches exactly one branch", () => { + expect(validator({ oneOf: [{ type: "string" }, { type: "number" }] } as any, "potato")).toBe(true); + }); + + it("validates when data matches exactly one branch including null", () => { + expect(validator({ oneOf: [{ type: "string" }, { type: "null" }] } as any, null)).toBe(true); + }); + + it("errors when data matches more than one branch", () => { + const result = validator({ oneOf: [{ type: "string" }, {}] } as any, "potato") as any; + + expect(result).toBeInstanceOf(ValidationErrors); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toBeInstanceOf(OneOfValidationError); + expect(result.errors[0].message).toContain("but 2 matched"); + }); + + it("errors when data matches no branches", () => { + const result = validator({ oneOf: [{ type: "string" }, { type: "number" }] } as any, true) as any; + + expect(result).toBeInstanceOf(ValidationErrors); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toBeInstanceOf(OneOfValidationError); + expect(result.errors[0].message).toContain("but 0 matched"); + }); + + it("supports oneOf nested inside an object property", () => { + const testSchema = { + type: "object", + properties: { + foo: { oneOf: [{ type: "string" }, { type: "number" }] }, + }, + } as JSONSchema; + + expect(validator(testSchema, { foo: "potato" })).toBe(true); + + const result = validator(testSchema, { foo: true }) as any; + expect(result).toBeInstanceOf(ValidationErrors); + expect(result.errors[0]).toBeInstanceOf(OneOfValidationError); + }); + + it("supports oneOf nested inside a oneOf branch", () => { + const testSchema = { + oneOf: [ + { oneOf: [{ type: "string" }, { type: "integer" }] }, + { type: "array" }, + ], + } as JSONSchema; + + expect(validator(testSchema, "potato")).toBe(true); + expect(validator(testSchema, [1, 2])).toBe(true); + + const result = validator(testSchema, 1.5) as any; + expect(result).toBeInstanceOf(ValidationErrors); + expect(result.errors[0]).toBeInstanceOf(OneOfValidationError); + }); + + it("still validates a data property literally named oneOf", () => { + const testSchema = { + oneOf: [{ type: "string" }, { type: "number" }], + properties: { + oneOf: { type: "string" }, + }, + } as JSONSchema; + + // the real oneOf is satisfied and the property named "oneOf" is a string + expect(validator(testSchema, "potato")).toBe(true); + + // the property literally named "oneOf" is validated (not skipped as a branch) + const result = validator(testSchema, true) as any; + expect(result).toBeInstanceOf(ValidationErrors); + expect(result.errors.length).toBe(2); + expect(result.errors[0]).toBeInstanceOf(StringValidationError); + expect(result.errors[1]).toBeInstanceOf(OneOfValidationError); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 5c4dada..91c766a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import NumberValidator, { NumberValidationError } from "./base-validators/number import IntegerValidator, { IntegerValidationError } from "./base-validators/integer"; import ObjectValidator, { ObjectValidationError } from "./base-validators/object"; import ArrayValidator, { ArrayValidationError } from "./base-validators/array"; +import { OneOfValidationError } from "./base-validators/oneOf"; import traverse from "@json-schema-tools/traverse"; import jsonpath from "jsonpath"; @@ -16,7 +17,8 @@ type ValidationError = IntegerValidationError | NumberValidationError | ObjectValidationError | - ArrayValidationError; + ArrayValidationError | + OneOfValidationError; export class ValidationErrors implements Error { public name = "ValidationErrors"; @@ -62,6 +64,20 @@ const validateItem = (schema: JSONSchema, data: any): true | ValidationError[] = } } + if (typeof schema !== "boolean" && schema.oneOf !== undefined) { + const matched = schema.oneOf + .filter((subschema) => validateItem(subschema, data) === true) + .length; + + if (matched !== 1) { + errors.push(new OneOfValidationError( + schema, + data, + `expected data to match exactly one schema in oneOf, but ${matched} matched`, + )); + } + } + if (errors.length === 0) { return true; } @@ -122,6 +138,15 @@ const validator = (schema: JSONSchema, data: any): true | ValidationErrors => { let errors: ValidationError[] = []; traverse(schema, (ss, isCycle, path) => { + // Subschemas beneath a oneOf keyword (e.g. `$.oneOf[0]`, `$.properties.foo.oneOf[1]`) + // are validated recursively by validateItem at the oneOf schema itself. Validating + // them here would check each branch against the parent schema's data, producing + // false errors. A data property literally named "oneOf" (e.g. `$.properties.oneOf`) + // carries no bracket index, so it is still validated here. + if (/(^|\.)oneOf\[\d+\]/.test(path)) { + return ss; + } + const regularPath = schemaPathToRegularPath(path); let reffed = data; if (data instanceof Object) {