diff --git a/src/index.test.ts b/src/index.test.ts index fe781bc..c3d702e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,6 +2,7 @@ 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"; describe("validator", () => { it("is a function", () => { @@ -180,4 +181,174 @@ describe("validator", () => { expect(result).toBe(true); }); }); + + describe("property names with special characters (#198)", () => { + it("validates a property with a dash in its name", () => { + const testSchema = { + title: "Validation Test", + type: "object", + properties: { + "Some-Prop": { type: "string" }, + }, + } as JSONSchema; + + expect(validator(testSchema, { "Some-Prop": "ok" })).toBe(true); + }); + + it("errors on the property itself when a dashed property has the wrong type", () => { + const testSchema = { + title: "Validation Test", + type: "object", + properties: { + "Some-Prop": { type: "string" }, + }, + } as JSONSchema; + + const result = validator(testSchema, { "Some-Prop": 42 }); + + expect(result).toBeInstanceOf(ValidationErrors); + expect((result as ValidationErrors).errors.length).toBe(1); + expect((result as ValidationErrors).errors[0]).toBeInstanceOf(StringValidationError); + }); + + it("validates a property with a space in its name", () => { + const testSchema = { + type: "object", + properties: { + "my prop": { type: "string" }, + }, + } as JSONSchema; + + expect(validator(testSchema, { "my prop": "ok" })).toBe(true); + + const result = validator(testSchema, { "my prop": 42 }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates a property with a dot in its name", () => { + const testSchema = { + type: "object", + properties: { + "a.b": { type: "string" }, + }, + } as JSONSchema; + + expect(validator(testSchema, { "a.b": "ok" })).toBe(true); + + const result = validator(testSchema, { "a.b": 42 }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates nested properties with special characters", () => { + const testSchema = { + type: "object", + properties: { + "a-b": { + type: "object", + properties: { + "c d": { type: "string" }, + }, + }, + }, + } as JSONSchema; + + expect(validator(testSchema, { "a-b": { "c d": "ok" } })).toBe(true); + + const result = validator(testSchema, { "a-b": { "c d": 42 } }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates a dashed property nested inside array items", () => { + const testSchema = { + type: "object", + properties: { + "my-arr": { + type: "array", + items: { + type: "object", + properties: { + "x-y": { type: "string" }, + }, + }, + }, + }, + } as JSONSchema; + + expect(validator(testSchema, { "my-arr": [{ "x-y": "ok" }] })).toBe(true); + + const result = validator(testSchema, { "my-arr": [{ "x-y": 42 }] }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates a dashed property inside tuple items", () => { + const testSchema = { + type: "object", + properties: { + tup: { + type: "array", + items: [ + { type: "string" }, + { + type: "object", + properties: { + "x-y": { type: "number" }, + }, + }, + ], + }, + }, + } as JSONSchema; + + expect(validator(testSchema, { tup: ["s", { "x-y": 1 }] })).toBe(true); + + const result = validator(testSchema, { tup: ["s", { "x-y": "nope" }] }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates a dashed property inside allOf", () => { + const testSchema = { + type: "object", + allOf: [ + { + properties: { + "Some-Prop": { type: "string" }, + }, + }, + ], + } as JSONSchema; + + expect(validator(testSchema, { "Some-Prop": "ok" })).toBe(true); + + const result = validator(testSchema, { "Some-Prop": 42 }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("validates a property with a double quote in its name", () => { + const testSchema = { + type: "object", + properties: { + 'a"b': { type: "string" }, + }, + } as JSONSchema; + + expect(validator(testSchema, { 'a"b': "ok" })).toBe(true); + + const result = validator(testSchema, { 'a"b': 42 }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + + it("treats a property literally named items as a property", () => { + const testSchema = { + type: "object", + properties: { + items: { type: "string" }, + }, + } as JSONSchema; + + expect(validator(testSchema, { items: "ok" })).toBe(true); + + const result = validator(testSchema, { items: 42 }); + expect(result).toBeInstanceOf(ValidationErrors); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 5c4dada..2d1caa6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,44 +79,73 @@ const validateItem = (schema: JSONSchema, data: any): true | ValidationError[] = * */ const validator = (schema: JSONSchema, data: any): true | ValidationErrors => { - // $.properties.foo -> $.foo - // $.items[0].properties.foo -> $[0].foo - // $.properties.foo.items[0].properties.bar -> $.foo[0].bar - const schemaPathToRegularPath = (path: string) => { - const tokens = path.split("."); - const regularPath: string[] = []; - - let pushNext = false; - tokens.forEach((t, i) => { - if (pushNext) { - regularPath.push(t); - pushNext = false; - return; + // Converts a traverse jsonpath (e.g. `$.properties["Some-Prop"].items[0]`) + // into a jsonpath over the data (e.g. `$["Some-Prop"][0]`). + // - `$.properties.foo` -> `$["foo"]` + // - `$.properties["Some-Prop"]` -> `$["Some-Prop"]` + // - `$.items[0].properties.foo` -> `$[0]["foo"]` + // - `$.properties.foo.items` (single schema) -> `$["foo"][*]` + const schemaPathToRegularPath = (path: string): string => { + // Tokenize the path without splitting inside ["quoted"] segments. Quoted + // names may contain dots, dashes, spaces, or escaped quotes (traverse >= + // 1.11 emits every non-identifier key this way). + const segments: (string | number)[] = []; + const tokenPattern = /\["((?:[^"\\]|\\.)*)"\]|\[(\d+)\]|\.([^.[\]]+)/g; + let match: RegExpExecArray | null; + const rest = path.slice(1); // strip the leading "$"; traverse paths are $-rooted + while ((match = tokenPattern.exec(rest)) !== null) { + if (match[1] !== undefined) { + // quoted property name; unescape (e.g. \" -> ") + segments.push(JSON.parse(`"${match[1]}"`)); + } else if (match[2] !== undefined) { + segments.push(Number(match[2])); + } else { + segments.push(match[3]); + } + } + + // jsonpath bracket-notation for a property name. The jsonpath lib does not + // process escapes inside "...", so names containing a double quote use '...'. + const bracketName = (name: string): string => + name.includes('"') ? `['${name}']` : `[${JSON.stringify(name)}]`; + + const regularPath: string[] = ["$"]; + let expectPropName = false; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + + // the segment after "properties" is always a property name, even when + // the name itself is "properties" or "items" + if (expectPropName) { + // traverse only ever emits string keys after "properties" + regularPath.push(bracketName(seg as string)); + expectPropName = false; + continue; } - if (t === "$") { - return regularPath.push(t); + if (seg === "properties") { + expectPropName = true; + continue; } - if (t.startsWith("items")) { - const l = regularPath.length - 1; - let toAppend = ""; - if (t.includes("[") && t.includes("]")) { - toAppend = t.replace("items", ""); + if (seg === "items") { + const next = segments[i + 1]; + if (typeof next === "number") { + // tuple items: $.properties.foo.items[0] -> $["foo"][0] + regularPath[regularPath.length - 1] += `[${next}]`; + i++; } else { - toAppend = "[*]"; + // single-schema items: $.properties.foo.items -> $["foo"][*] + regularPath[regularPath.length - 1] += "[*]"; } - - regularPath[l] += toAppend - return; + continue; } - if (t === "properties") { - pushNext = true; - return; - } - }); - return regularPath.join("."); + // any other keyword (allOf[0], patternProperties, ...) has no data-path + // mapping; its subschema is validated against the enclosing data, as before + } + + return regularPath.join(""); }; let errors: ValidationError[] = [];