From a8f39e3e8e47af81a0aff87a53fdf99787663c99 Mon Sep 17 00:00:00 2001 From: belfordzjeeves Date: Sat, 17 Oct 2026 05:24:35 +0000 Subject: [PATCH] fix: support dashes/spaces in property names Fixes #198. schemaPathToRegularPath split traverse paths on '.', which broke once traverse >= 1.11 started emitting bracket-quoted segments ($['Some-Prop']) for non-identifier keys: the quoted token matched neither the 'properties' nor the 'items' branch and was dropped, so the property subschema was validated against the whole data object. The path conversion now tokenizes traverse paths properly (quoted segments are never split on '.'; escaped quotes unescaped) and emits jsonpath bracket notation for property names, e.g. $.properties['Some-Prop'].items[0] -> $['Some-Prop'][0]. Preserves existing behavior for bare properties, single-schema items ([*]), tuple items[n], and non-properties keywords (allOf[n], ...). --- src/index.test.ts | 171 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 89 ++++++++++++++++-------- 2 files changed, 230 insertions(+), 30 deletions(-) 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[] = [];