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) {