Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});
89 changes: 59 additions & 30 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading