diff --git a/src/app/template_helpers.ts b/src/app/template_helpers.ts index 65fe750d..6653d8a7 100644 --- a/src/app/template_helpers.ts +++ b/src/app/template_helpers.ts @@ -1,7 +1,8 @@ import { BDom, multi, text, toggler, createCatcher } from "../blockdom"; -import { validateProps } from "../component/props_validation"; import { Markup } from "../utils"; import { html } from "../blockdom/index"; +import { isOptional, validateSchema } from "../validation"; +import { ComponentConstructor } from "../component/component"; /** * This file contains utility functions that will be injected in each template, @@ -182,6 +183,49 @@ function multiRefSetter(refs: RefMap, name: string): RefSetter { }; } +/** + * Validate the component props (or next props) against the (static) props + * description. This is potentially an expensive operation: it may needs to + * visit recursively the props and all the children to check if they are valid. + * This is why it is only done in 'dev' mode. + */ +export function validateProps
(name: string | ComponentConstructor
, props: P, parent?: any) { + const ComponentClass = + typeof name !== "string" + ? name + : (parent.constructor.components[name] as ComponentConstructor
| undefined); + + if (!ComponentClass) { + // this is an error, wrong component. We silently return here instead so the + // error is triggered by the usual path ('component' function) + return; + } + + const schema = ComponentClass.props; + if (!schema) { + return; + } + const defaultProps = ComponentClass.defaultProps; + if (defaultProps) { + let isMandatory = (name: string) => + Array.isArray(schema) + ? schema.includes(name) + : name in schema && !("*" in schema) && !isOptional(schema[name]); + for (let p in defaultProps) { + if (isMandatory(p)) { + throw new Error( + `A default value cannot be defined for a mandatory prop (name: '${p}', component: ${ComponentClass.name})` + ); + } + } + } + + const errors = validateSchema(props, schema); + if (errors.length) { + throw new Error(`Invalid props for component '${ComponentClass.name}': ` + errors.join(", ")); + } +} + export const helpers = { withDefault, zero: Symbol("zero"), diff --git a/src/component/component.ts b/src/component/component.ts index 0a66b4b4..3e67a041 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -1,3 +1,4 @@ +import { Schema } from "../validation"; import type { ComponentNode } from "./component_node"; // ----------------------------------------------------------------------------- @@ -9,7 +10,7 @@ type Props = { [key: string]: any }; interface StaticComponentProperties { template: string; defaultProps?: any; - props?: any; + props?: Schema; components?: { [componentName: string]: ComponentConstructor }; } diff --git a/src/component/component_node.ts b/src/component/component_node.ts index db613462..599c0e67 100644 --- a/src/component/component_node.ts +++ b/src/component/component_node.ts @@ -13,7 +13,6 @@ import { batched, Callback } from "../utils"; import { Component, ComponentConstructor } from "./component"; import { fibersInError, handleError } from "./error_handling"; import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers"; -import { applyDefaultProps } from "./props_validation"; import { STATUS } from "./status"; let currentNode: ComponentNode | null = null; @@ -29,6 +28,18 @@ export function useComponent(): Component { return currentNode!.component; } +/** + * Apply default props (only top level). + * + * Note that this method does modify in place the props + */ +function applyDefaultProps
(props: P, defaultProps: Partial
) { + for (let propName in defaultProps) { + if ((props as any)[propName] === undefined) { + (props as any)[propName] = defaultProps[propName]; + } + } +} // ----------------------------------------------------------------------------- // Integration with reactivity system (useState) // ----------------------------------------------------------------------------- @@ -165,7 +176,10 @@ export class ComponentNode
implements VNode ) {
- const defaultProps = ComponentClass.defaultProps;
- if (defaultProps) {
- for (let propName in defaultProps) {
- if ((props as any)[propName] === undefined) {
- (props as any)[propName] = defaultProps[propName];
- }
- }
- }
-}
-
-//------------------------------------------------------------------------------
-// Prop validation helper
-//------------------------------------------------------------------------------
-function getPropDescription(staticProps: any) {
- if (staticProps instanceof Array) {
- return Object.fromEntries(
- staticProps.map((p) => (p.endsWith("?") ? [p.slice(0, -1), false] : [p, true]))
- );
- }
- return staticProps || { "*": true };
-}
-
-/**
- * Validate the component props (or next props) against the (static) props
- * description. This is potentially an expensive operation: it may needs to
- * visit recursively the props and all the children to check if they are valid.
- * This is why it is only done in 'dev' mode.
- */
-export function validateProps (name: string | ComponentConstructor , props: P, parent?: any) {
- const ComponentClass =
- typeof name !== "string"
- ? name
- : (parent.constructor.components[name] as ComponentConstructor | undefined);
-
- if (!ComponentClass) {
- // this is an error, wrong component. We silently return here instead so the
- // error is triggered by the usual path ('component' function)
- return;
- }
- applyDefaultProps(props, ComponentClass);
-
- const defaultProps = ComponentClass.defaultProps || {};
- let propsDef = getPropDescription(ComponentClass.props);
- const allowAdditionalProps = "*" in propsDef;
-
- for (let propName in propsDef) {
- if (propName === "*") {
- continue;
- }
- const propDef = propsDef[propName];
- let isMandatory = !!propDef;
- if (typeof propDef === "object" && "optional" in propDef) {
- isMandatory = !propDef.optional;
- }
- if (isMandatory && propName in defaultProps) {
- throw new Error(
- `A default value cannot be defined for a mandatory prop (name: '${propName}', component: ${ComponentClass.name})`
- );
- }
- if ((props as any)[propName] === undefined) {
- if (isMandatory) {
- throw new Error(`Missing props '${propName}' (component '${ComponentClass.name}')`);
- } else {
- continue;
- }
- }
- let whyInvalid;
- try {
- whyInvalid = whyInvalidProp((props as any)[propName], propDef);
- } catch (e) {
- (e as Error).message = `Invalid prop '${propName}' in component ${ComponentClass.name} (${
- (e as Error).message
- })`;
- throw e;
- }
- if (whyInvalid !== null) {
- whyInvalid = whyInvalid.replace(/\${propName}/g, propName);
- throw new Error(
- `Invalid Prop '${propName}' in component '${ComponentClass.name}': ${whyInvalid}`
- );
- }
- }
- if (!allowAdditionalProps) {
- for (let propName in props) {
- if (!(propName in propsDef)) {
- throw new Error(`Unknown prop '${propName}' given to component '${ComponentClass.name}'`);
- }
- }
- }
-}
-
-/**
- * Check why an invidual prop value doesn't match its (static) prop definition
- */
-function whyInvalidProp(prop: any, propDef: any): string | null {
- if (propDef === true) {
- return null;
- }
- if (typeof propDef === "function") {
- // Check if a value is constructed by some Constructor. Note that there is a
- // slight abuse of language: we want to consider primitive values as well.
- //
- // So, even though 1 is not an instance of Number, we want to consider that
- // it is valid.
- if (typeof prop === "object") {
- if (prop instanceof propDef) {
- return null;
- }
- return `\${propName} is not an instance of ${propDef.name}`;
- }
- if (typeof prop === propDef.name.toLowerCase()) {
- return null;
- }
- return `type of \${propName} is not ${propDef.name}`;
- } else if (propDef instanceof Array) {
- // If this code is executed, this means that we want to check if a prop
- // matches at least one of its descriptor.
- let reasons: string[] = [];
- for (let i = 0, iLen = propDef.length; i < iLen; i++) {
- const why = whyInvalidProp(prop, propDef[i]);
- if (why === null) {
- return null;
- }
- reasons.push(why);
- }
- if (reasons.length > 1) {
- return reasons.slice(0, -1).join(", ") + " and " + reasons[reasons.length - 1];
- } else {
- return reasons[0];
- }
- }
- // propsDef is an object
- if (propDef.optional && prop === undefined) {
- return null;
- }
- if (propDef.type) {
- const why = whyInvalidProp(prop, propDef.type);
- if (why !== null) {
- return why;
- }
- }
- if (propDef.validate && !propDef.validate(prop)) {
- return "${propName} could not be validated by `validate` function";
- }
- if (propDef.type === Array && propDef.element) {
- for (let i = 0, iLen = prop.length; i < iLen; i++) {
- const why = whyInvalidProp(prop[i], propDef.element);
- if (why !== null) {
- return why.replace(/\${propName}/g, `\${propName}[${i}]`);
- }
- }
- }
- if (propDef.type === Object && propDef.shape) {
- const shape = propDef.shape;
- for (let key in shape) {
- const why = whyInvalidProp(prop[key], shape[key]);
- if (why !== null) {
- return why.replace(/\${propName}/g, `\${propName}['${key}']`);
- }
- }
- for (let propName in prop) {
- if (!(propName in shape)) {
- return `unknown prop \${propName}['${propName}']`;
- }
- }
- }
- return null;
-}
diff --git a/src/validation.ts b/src/validation.ts
new file mode 100644
index 00000000..1015ed32
--- /dev/null
+++ b/src/validation.ts
@@ -0,0 +1,161 @@
+type BaseType =
+ | typeof String
+ | typeof Boolean
+ | typeof Number
+ | typeof Date
+ | typeof Object
+ | typeof Array
+ | true
+ | "*";
+
+type UnionType = (TypeInfo | BaseType)[];
+type TypeDescription = BaseType | UnionType | TypeInfo;
+
+interface TypeInfo {
+ type?: TypeDescription;
+ optional?: boolean;
+ validate?: Function;
+ shape?: Schema;
+ element?: TypeDescription;
+}
+
+type SimplifiedSchema = string[];
+type NormalizedSchema = { [key: string]: TypeDescription };
+export type Schema = SimplifiedSchema | NormalizedSchema;
+
+// -----------------------------------------------------------------------------
+// helpers
+// -----------------------------------------------------------------------------
+const isUnionType = (t: TypeDescription): t is UnionType => Array.isArray(t);
+const isBaseType = (t: TypeDescription): t is BaseType => typeof t !== "object";
+
+export function isOptional(t: TypeDescription): Boolean {
+ return typeof t === "object" && "optional" in t ? t.optional || false : false;
+}
+
+function describeType(type: BaseType): string {
+ return type === "*" || type === true ? "value" : type.name.toLowerCase();
+}
+
+function describe(info: TypeDescription): string {
+ if (isBaseType(info)) {
+ return describeType(info);
+ } else if (isUnionType(info)) {
+ return info.map(describe).join(" or ");
+ }
+ if ("element" in info) {
+ return `list of ${describe({ type: info.element, optional: false })}s`;
+ }
+ if ("shape" in (info as TypeInfo)) {
+ return `object`;
+ }
+ return describe(info.type || "*");
+}
+
+function toSchema(spec: SimplifiedSchema): NormalizedSchema {
+ return Object.fromEntries(
+ spec.map((e) =>
+ e.endsWith("?") ? [e.slice(0, -1), { optional: true }] : [e, { type: "*", optional: false }]
+ )
+ );
+}
+
+/**
+ * Main validate function
+ */
+export function validate(obj: { [key: string]: any }, spec: Schema) {
+ let errors = validateSchema(obj, spec);
+ if (errors.length) {
+ throw new Error("Invalid object: " + errors.join(", "));
+ }
+}
+
+/**
+ * Helper validate function, to get the list of errors. useful if one want to
+ * manipulate the errors without parsing an error object
+ */
+export function validateSchema(obj: { [key: string]: any }, schema: Schema): string[] {
+ if (Array.isArray(schema)) {
+ schema = toSchema(schema);
+ }
+ let errors = [];
+ // check if each value in obj has correct shape
+ for (let key in obj) {
+ if (key in schema) {
+ let result = validateType(key, obj[key], schema[key]);
+ if (result) {
+ errors.push(result);
+ }
+ } else if (!("*" in schema)) {
+ errors.push(`unknown key '${key}'`);
+ }
+ }
+ // check that all specified keys are defined in obj
+ for (let key in schema) {
+ const spec = schema[key];
+ if (key !== "*" && !isOptional(spec) && !(key in obj)) {
+ const isObj = typeof spec === "object" && !Array.isArray(spec);
+ const isAny = spec === "*" || (isObj && "type" in spec ? spec.type === "*" : isObj);
+ let detail = isAny ? "" : ` (should be a ${describe(spec)})`;
+ errors.push(`'${key}' is missing${detail}`);
+ }
+ }
+ return errors;
+}
+
+function validateBaseType(key: string, value: any, type: BaseType): string | null {
+ if (typeof type === "function") {
+ if (typeof value === "object") {
+ if (!(value instanceof type)) {
+ return `'${key}' is not a ${describeType(type)}`;
+ }
+ } else if (typeof value !== type.name.toLowerCase()) {
+ return `'${key}' is not a ${describeType(type)}`;
+ }
+ }
+ return null;
+}
+
+function validateArrayType(key: string, value: any, descr: TypeDescription): string | null {
+ if (!Array.isArray(value)) {
+ return `'${key}' is not a list of ${describe(descr)}s`;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const error = validateType(`${key}[${i}]`, value[i], descr);
+ if (error) {
+ return error;
+ }
+ }
+ return null;
+}
+
+function validateType(key: string, value: any, descr: TypeDescription): string | null {
+ if (value === undefined) {
+ return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`;
+ } else if (isBaseType(descr)) {
+ return validateBaseType(key, value, descr);
+ } else if (isUnionType(descr)) {
+ let validDescr = descr.find((p) => !validateType(key, value, p));
+ return validDescr ? null : `'${key}' is not a ${describe(descr)}`;
+ }
+ let result: string | null = null;
+ if ("element" in descr) {
+ result = validateArrayType(key, value, descr.element!);
+ } else if ("shape" in descr && !result) {
+ if (typeof value !== "object" || Array.isArray(value)) {
+ result = `'${key}' is not an object`;
+ } else {
+ const errors = validateSchema(value, descr.shape!);
+ if (errors.length) {
+ result = `'${key}' has not the correct shape (${errors.join(", ")})`;
+ }
+ }
+ }
+ if ("type" in descr && !result) {
+ result = validateType(key, value, descr.type!);
+ }
+ if ("validate" in descr && !result) {
+ result = !descr.validate!(value) ? `'${key}' is not valid` : null;
+ }
+ return result;
+}
diff --git a/tests/components/props_validation.test.ts b/tests/components/props_validation.test.ts
index adb3de8e..d21b4cac 100644
--- a/tests/components/props_validation.test.ts
+++ b/tests/components/props_validation.test.ts
@@ -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,
};
diff --git a/tests/validation.test.ts b/tests/validation.test.ts
new file mode 100644
index 00000000..6aa1bf24
--- /dev/null
+++ b/tests/validation.test.ts
@@ -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"]);
+ });
+});