[IMP] component: rewrite props validation code

This commit reworks the props validation code in order to extract a
generic validate utility function. The validation should be more robust,
with better error messages, and at the same time, it supports `*` in a
shape object.

And as a bonus, it is now typesafe, and the static props object is now
typed.

closes #1190
This commit is contained in:
Géry Debongnie
2022-05-12 22:49:41 +02:00
parent d917af4614
commit 4779707923
7 changed files with 514 additions and 211 deletions
+35 -30
View File
@@ -1,7 +1,8 @@
import { makeTestFixture, nextTick, snapshotEverything } from "../helpers";
import { Component, onError, xml, mount } from "../../src";
import { DEV_MSG } from "../../src/app/app";
import { validateProps } from "../../src/component/props_validation";
import { validateProps } from "../../src/app/template_helpers";
import { Schema } from "../../src/validation";
let fixture: HTMLElement;
@@ -55,7 +56,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(`Missing props 'message' (component 'SubComp')`);
expect(error!.message).toBe("Invalid props for component 'SubComp': 'message' is missing");
error = undefined;
try {
@@ -83,7 +84,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(`Missing props 'message' (component 'SubComp')`);
expect(error!.message).toBe("Invalid props for component 'SubComp': 'message' is missing");
});
test("validate simple types", async () => {
@@ -118,7 +119,9 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(`Missing props 'p' (component '_a')`);
expect(error!.message).toBe(
`Invalid props for component '_a': 'p' is undefined (should be a ${test.type.name.toLowerCase()})`
);
error = undefined;
props = { p: test.ok };
try {
@@ -135,7 +138,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
`Invalid Prop 'p' in component '_a': type of p is not ${test.type.name}`
`Invalid props for component '_a': 'p' is not a ${test.type.name.toLowerCase()}`
);
}
});
@@ -170,7 +173,9 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(`Missing props 'p' (component '_a')`);
expect(error!.message).toBe(
`Invalid props for component '_a': 'p' is undefined (should be a ${test.type.name.toLowerCase()})`
);
error = undefined;
props = { p: test.ok };
try {
@@ -187,7 +192,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
`Invalid Prop 'p' in component '_a': type of p is not ${test.type.name}`
`Invalid props for component '_a': 'p' is not a ${test.type.name.toLowerCase()}`
);
}
});
@@ -228,7 +233,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'p' in component 'SubComp': type of p is not String and type of p is not Boolean"
"Invalid props for component 'SubComp': 'p' is not a string or boolean"
);
});
@@ -267,7 +272,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe("Invalid Prop 'p' in component 'SubComp': type of p is not String");
expect(error!.message).toBe("Invalid props for component 'SubComp': 'p' is not a string");
});
test("can validate an array with given primitive type", async () => {
@@ -357,7 +362,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'p' in component 'SubComp': type of p[1] is not String and type of p[1] is not Boolean"
"Invalid props for component 'SubComp': 'p[1]' is not a string or boolean"
);
});
@@ -391,7 +396,9 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe("Invalid Prop 'p' in component 'SubComp': unknown prop p['extra']");
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' has not the correct shape (unknown key 'extra')"
);
try {
props = { p: { id: "1", url: "url" } };
await mount(Parent, fixture, { dev: true });
@@ -400,7 +407,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'p' in component 'SubComp': type of p['id'] is not Number"
"Invalid props for component 'SubComp': 'p' has not the correct shape ('id' is not a number)"
);
error = undefined;
try {
@@ -411,7 +418,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'p' in component 'SubComp': type of p['url'] is not String"
"Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is missing (should be a string))"
);
});
@@ -459,7 +466,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'p' in component 'SubComp': p['url'] is not an instance of Boolean and type of p['url'][1] is not Number"
"Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is not a boolean or list of numbers)"
);
});
@@ -491,7 +498,7 @@ describe("props validation", () => {
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'myprop' in component 'TestComponent': unknown prop myprop[0]['a']"
"Invalid props for component 'TestComponent': 'myprop[0]' has not the correct shape (unknown key 'a')"
);
});
@@ -516,9 +523,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'size' in component 'TestComponent': size could not be validated by `validate` function"
);
expect(error!.message).toBe("Invalid props for component 'TestComponent': 'size' is not valid");
});
test("can validate with a custom validator, and a type", () => {
@@ -545,9 +550,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'n' in component 'TestComponent': type of n is not Number"
);
expect(error!.message).toBe("Invalid props for component 'TestComponent': 'n' is not a number");
expect(validator).toBeCalledTimes(1);
error = undefined;
try {
@@ -556,9 +559,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Invalid Prop 'n' in component 'TestComponent': n could not be validated by `validate` function"
);
expect(error!.message).toBe("Invalid props for component 'TestComponent': 'n' is not valid");
expect(validator).toBeCalledTimes(2);
});
@@ -605,7 +606,7 @@ describe("props validation", () => {
}).not.toThrow();
expect(() => {
validateProps(SubComp as any, { myprop: 1 });
}).toThrow(`Invalid Prop 'myprop' in component 'SubComp'`);
}).toThrow("Invalid props for component 'SubComp': 'myprop' is not a array");
});
test("props with type object, and no shape", async () => {
@@ -617,7 +618,7 @@ describe("props validation", () => {
}).not.toThrow();
expect(() => {
validateProps(SubComp as any, { myprop: false });
}).toThrow(`Invalid Prop 'myprop' in component 'SubComp'`);
}).toThrow("Invalid props for component 'SubComp': 'myprop' is not a object");
});
test("props: extra props cause an error", async () => {
@@ -675,7 +676,7 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe("Missing props 'p' (component 'SubComp')");
expect(error!.message).toBe("Invalid props for component 'SubComp': 'p' is missing");
});
test("props are validated whenever component is updated", async () => {
@@ -698,7 +699,9 @@ describe("props validation", () => {
parent.render();
await nextTick();
expect(error!).toBeDefined();
expect(error!.message).toBe("Missing props 'p' (component 'SubComp')");
expect(error!.message).toBe(
"Invalid props for component 'SubComp': 'p' is undefined (should be a number)"
);
});
test("default values are applied before validating props at update", async () => {
@@ -741,7 +744,9 @@ describe("props validation", () => {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe("Missing props 'mandatory' (component 'Child')");
expect(error!.message).toBe(
"Invalid props for component 'Child': 'mandatory' is missing (should be a number)"
);
});
test("can specify that additional props are allowed (array)", async () => {
@@ -759,7 +764,7 @@ describe("props validation", () => {
test("can specify that additional props are allowed (object)", async () => {
class Child extends Component {
static props = {
static props: Schema = {
message: { type: String },
"*": true,
};
+251
View File
@@ -0,0 +1,251 @@
import { Schema, validateSchema } from "../src/validation";
describe("validateSchema", () => {
test("simple use", () => {
expect(validateSchema({ a: "hey" }, { a: String })).toEqual([]);
expect(validateSchema({ a: 1 }, { a: Boolean })).toEqual(["'a' is not a boolean"]);
});
test("simple use, alternate form", () => {
expect(validateSchema({ a: "hey" }, { a: { type: String } })).toEqual([]);
expect(validateSchema({ a: 1 }, { a: { type: Boolean } })).toEqual(["'a' is not a boolean"]);
});
test("some particular edgecases as key name", () => {
expect(validateSchema({ shape: "hey" }, { shape: String })).toEqual([]);
expect(validateSchema({ shape: 1 }, { shape: Boolean })).toEqual(["'shape' is not a boolean"]);
expect(validateSchema({ element: "hey" }, { element: String })).toEqual([]);
expect(validateSchema({ element: 1 }, { element: Boolean })).toEqual([
"'element' is not a boolean",
]);
});
test("multiple errors", () => {
expect(validateSchema({ a: 1, b: 2 }, { a: Boolean, b: Boolean })).toEqual([
"'a' is not a boolean",
"'b' is not a boolean",
]);
});
test("missing key", () => {
expect(validateSchema({}, { a: Boolean })).toEqual(["'a' is missing (should be a boolean)"]);
});
test("additional key", () => {
expect(validateSchema({ b: 1 }, {})).toEqual(["unknown key 'b'"]);
});
test("undefined key", () => {
expect(validateSchema({ a: undefined }, { a: Boolean })).toEqual([
"'a' is undefined (should be a boolean)",
]);
expect(validateSchema({}, { a: Boolean })).toEqual(["'a' is missing (should be a boolean)"]);
});
test("can use '*' to denote any type", () => {
expect(validateSchema({ a: "hey" }, { a: "*" })).toEqual([]);
expect(validateSchema({}, { a: "*" })).toEqual(["'a' is missing"]);
});
test("an union of type", () => {
expect(validateSchema({ a: "hey" }, { a: [String, Boolean] })).toEqual([]);
expect(validateSchema({ a: 1 }, { a: [String, Boolean] })).toEqual([
"'a' is not a string or boolean",
]);
expect(validateSchema({ a: "hey" }, { a: { type: [String, Boolean] } })).toEqual([]);
});
test("another union of types", () => {
const schema: Schema = {
id: Number,
url: [Boolean, { type: Array, element: Number }],
};
expect(validateSchema({ a: "hey" }, schema)).toEqual([
"unknown key 'a'",
"'id' is missing (should be a number)",
"'url' is missing (should be a boolean or list of numbers)",
]);
expect(validateSchema({ id: 1 }, schema)).toEqual([
"'url' is missing (should be a boolean or list of numbers)",
]);
expect(validateSchema({ id: 1, url: true }, schema)).toEqual([]);
expect(validateSchema({ id: true, url: true }, schema)).toEqual(["'id' is not a number"]);
expect(validateSchema({ id: 3, url: 3 }, schema)).toEqual([
"'url' is not a boolean or list of numbers",
]);
});
test("simplified schema description", () => {
expect(validateSchema({ a: "hey" }, ["a"])).toEqual([]);
expect(validateSchema({ b: 1 }, ["a"])).toEqual(["unknown key 'b'", "'a' is missing"]);
});
test("simplified schema description with optional props and *", () => {
expect(validateSchema({ a: "hey" }, ["a", "b?", "*"])).toEqual([]);
expect(validateSchema({ a: "hey" }, ["a", "*"])).toEqual([]);
expect(validateSchema({ a: "hey", b: 1, c: 3 }, ["a", "*"])).toEqual([]);
});
test("simplified schema description with optional props", () => {
expect(validateSchema({ a: "hey" }, ["a", "b?"])).toEqual([]);
expect(validateSchema({ a: "hey", b: 1 }, ["a", "b?"])).toEqual([]);
});
test("object type description, with no type/optional key", () => {
expect(validateSchema({ a: "hey" }, { a: {} })).toEqual([]);
expect(validateSchema({ a: 1 }, { a: {} })).toEqual([]);
expect(validateSchema({}, { a: {} })).toEqual(["'a' is missing"]);
});
test("optional key", () => {
expect(validateSchema({}, { a: { optional: true } })).toEqual([]);
expect(validateSchema({}, { a: { type: Number, optional: true } })).toEqual([]);
expect(validateSchema({ a: undefined }, { a: { type: Number, optional: true } })).toEqual([]);
expect(validateSchema({ a: 2 }, { a: { optional: true } })).toEqual([]);
expect(validateSchema({ a: undefined }, { a: { optional: true } })).toEqual([]);
expect(validateSchema({ a: 2 }, { a: { type: Number, optional: true } })).toEqual([]);
expect(validateSchema({ a: 2 }, { a: { type: String, optional: true } })).toEqual([
"'a' is not a string",
]);
});
test("can validate dates", () => {
expect(validateSchema({ a: new Date() }, { a: Date })).toEqual([]);
expect(validateSchema({ a: 4 }, { a: Date })).toEqual(["'a' is not a date"]);
});
test("arrays with simple element description", () => {
const schema: Schema = { p: { type: Array, element: String } };
expect(validateSchema({ p: [] }, schema)).toEqual([]);
expect(validateSchema({ p: 1 }, schema)).toEqual(["'p' is not a list of strings"]);
expect(validateSchema({}, schema)).toEqual(["'p' is missing (should be a list of strings)"]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a list of strings)",
]);
expect(validateSchema({ p: ["a"] }, schema)).toEqual([]);
expect(validateSchema({ p: [1] }, schema)).toEqual(["'p[0]' is not a string"]);
});
test("arrays with union type as element description", () => {
const schema: Schema = { p: { type: Array, element: [String, Boolean] } };
expect(validateSchema({ p: [] }, schema)).toEqual([]);
expect(validateSchema({ p: 1 }, schema)).toEqual(["'p' is not a list of string or booleans"]);
expect(validateSchema({}, schema)).toEqual([
"'p' is missing (should be a list of string or booleans)",
]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a list of string or booleans)",
]);
expect(validateSchema({ p: ["a"] }, schema)).toEqual([]);
expect(validateSchema({ p: [1] }, schema)).toEqual(["'p[0]' is not a string or boolean"]);
expect(validateSchema({ p: [true, 1] }, schema)).toEqual(["'p[1]' is not a string or boolean"]);
});
test("objects with specified shape", () => {
const schema: Schema = { p: { type: Object, shape: { id: Number, url: String } } };
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a string))",
]);
expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([]);
expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([
"'p' has not the correct shape ('url' is not a string)",
]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a object)",
]);
});
test("objects with more complex shape", () => {
const schema: Schema = {
p: {
type: Object,
shape: {
id: Number,
url: [Boolean, { type: Array, element: Number }],
},
},
};
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a boolean or list of numbers))",
]);
expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([
"'p' has not the correct shape ('url' is not a boolean or list of numbers)",
]);
expect(validateSchema({ p: { id: 1, url: true } }, schema)).toEqual([]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a object)",
]);
});
test("objects with shape and *", () => {
const schema: Schema = { p: { type: Object, shape: { id: Number, "*": true } } };
expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]);
expect(validateSchema({ p: {} }, schema)).toEqual([
"'p' has not the correct shape ('id' is missing (should be a number))",
]);
expect(validateSchema({ p: { id: 1 } }, schema)).toEqual([]);
expect(validateSchema({ p: { id: "asdf" } }, schema)).toEqual([
"'p' has not the correct shape ('id' is not a number)",
]);
expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([]);
expect(validateSchema({ p: undefined }, schema)).toEqual([
"'p' is undefined (should be a object)",
]);
});
test("can specify that additional keys are allowed", () => {
const schema: Schema = {
message: String,
"*": true,
};
expect(validateSchema({ message: "hey" }, schema)).toEqual([]);
expect(validateSchema({ message: "hey", otherKey: true }, schema)).toEqual([]);
});
test("array with element with shape", () => {
const schema: Schema = {
p: {
type: Array,
element: {
type: Object,
shape: {
num: { type: Number, optional: true },
},
},
},
};
expect(validateSchema({ p: 1 }, schema)).toEqual(["'p' is not a list of objects"]);
expect(validateSchema({ p: {} }, schema)).toEqual(["'p' is not a list of objects"]);
expect(validateSchema({ p: [] }, schema)).toEqual([]);
expect(validateSchema({ p: [{}] }, schema)).toEqual([]);
expect(validateSchema({ p: [{ num: 1 }] }, schema)).toEqual([]);
expect(validateSchema({ p: [{ num: true }] }, schema)).toEqual([
"'p[0]' has not the correct shape ('num' is not a number)",
]);
});
test("schema with custom validate function", () => {
const schema: Schema = {
size: {
validate: (e: string) => ["small", "medium", "large"].includes(e),
},
};
expect(validateSchema({ size: "small" }, schema)).toEqual([]);
expect(validateSchema({ size: "sall" }, schema)).toEqual(["'size' is not valid"]);
expect(validateSchema({ size: 1 }, schema)).toEqual(["'size' is not valid"]);
});
test("schema with custom validate function and type", () => {
const schema: Schema = {
size: {
type: String,
validate: (e: string) => ["small", "medium", "large"].includes(e),
},
};
expect(validateSchema({ size: "small" }, schema)).toEqual([]);
expect(validateSchema({ size: "sall" }, schema)).toEqual(["'size' is not valid"]);
expect(validateSchema({ size: 1 }, schema)).toEqual(["'size' is not a string"]);
});
});