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 implements VNode(props: P, ComponentClass: ComponentConstructor

) { - 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"]); + }); +});