/* This file is part of TALER (C) 2016 GNUnet e.V. TALER is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. TALER is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with TALER; see the file COPYING. If not, see */ /** * Decorators for validating JSON objects and converting them to a typed * object. * * The decorators are put onto classes, and the validation is done * via a static method that is filled in by the annotation. * * Example: * ``` * @Checkable.Class * class Person { * @Checkable.String * name: string; * @Checkable.Number * age: number; * * // Method will be implemented automatically * static checked(obj: any): Person; * } * ``` */ export namespace Checkable { type Path = Array; interface SchemaErrorConstructor { new (err: string): SchemaError; } interface SchemaError { name: string; message: string; } interface Prop { propertyKey: any; checker: any; type?: any; typeThunk?: () => any; elementChecker?: any; elementProp?: any; keyProp?: any; stringChecker?: (s: string) => boolean; valueProp?: any; optional?: boolean; extraAllowed?: boolean; } interface CheckableInfo { props: Prop[]; } // tslint:disable-next-line:no-shadowed-variable export const SchemaError = (function SchemaError(this: any, message: string) { const that: any = this as any; that.name = "SchemaError"; that.message = message; that.stack = (new Error() as any).stack; }) as any as SchemaErrorConstructor; SchemaError.prototype = new Error(); /** * Classes that are checkable are annotated with this * checkable info symbol, which contains the information necessary * to check if they're valid. */ const checkableInfoSym = Symbol("checkableInfo"); /** * Get the current property list for a checkable type. */ function getCheckableInfo(target: any): CheckableInfo { let chk = target[checkableInfoSym] as CheckableInfo|undefined; if (!chk) { chk = { props: [] }; target[checkableInfoSym] = chk; } return chk; } function checkNumber(target: any, prop: Prop, path: Path): any { if ((typeof target) !== "number") { throw new SchemaError(`expected number for ${path}`); } return target; } function checkString(target: any, prop: Prop, path: Path): any { if (typeof target !== "string") { throw new SchemaError(`expected string for ${path}, got ${typeof target} instead`); } if (prop.stringChecker && !prop.stringChecker(target)) { throw new SchemaError(`string property ${path} malformed`); } return target; } function checkBoolean(target: any, prop: Prop, path: Path): any { if (typeof target !== "boolean") { throw new SchemaError(`expected boolean for ${path}, got ${typeof target} instead`); } return target; } function checkAnyObject(target: any, prop: Prop, path: Path): any { if (typeof target !== "object") { throw new SchemaError(`expected (any) object for ${path}, got ${typeof target} instead`); } return target; } function checkAny(target: any, prop: Prop, path: Path): any { return target; } function checkList(target: any, prop: Prop, path: Path): any { if (!Array.isArray(target)) { throw new SchemaError(`array expected for ${path}, got ${typeof target} instead`); } for (let i = 0; i < target.length; i++) { const v = target[i]; prop.elementChecker(v, prop.elementProp, path.concat([i])); } return target; } function checkMap(target: any, prop: Prop, path: Path): any { if (typeof target !== "object") { throw new SchemaError(`expected object for ${path}, got ${typeof target} instead`); } for (const key in target) { prop.keyProp.checker(key, prop.keyProp, path.concat([key])); const value = target[key]; prop.valueProp.checker(value, prop.valueProp, path.concat([key])); } } function checkOptional(target: any, prop: Prop, path: Path): any { console.assert(prop.propertyKey); prop.elementChecker(target, prop.elementProp, path.concat([prop.propertyKey])); return target; } function checkValue(target: any, prop: Prop, path: Path): any { let type; if (prop.type) { type = prop.type; } else if (prop.typeThunk) { type = prop.typeThunk(); if (!type) { throw Error(`assertion failed: typeThunk returned null (prop is ${JSON.stringify(prop)})`); } } else { throw Error(`assertion failed: type/typeThunk missing (prop is ${JSON.stringify(prop)})`); } const typeName = type.name || "??"; const v = target; if (!v || typeof v !== "object") { throw new SchemaError( `expected object for ${path.join(".")}, got ${typeof v} instead`); } const props = type.prototype[checkableInfoSym].props; const remainingPropNames = new Set(Object.getOwnPropertyNames(v)); const obj = new type(); for (const innerProp of props) { if (!remainingPropNames.has(innerProp.propertyKey)) { if (innerProp.optional) { continue; } throw new SchemaError(`Property '${innerProp.propertyKey}' missing on '${path}' of '${typeName}'`); } if (!remainingPropNames.delete(innerProp.propertyKey)) { throw new SchemaError("assertion failed"); } const propVal = v[innerProp.propertyKey]; obj[innerProp.propertyKey] = innerProp.checker(propVal, innerProp, path.concat([innerProp.propertyKey])); } if (!prop.extraAllowed && remainingPropNames.size !== 0) { const err = `superfluous properties ${JSON.stringify(Array.from(remainingPropNames.values()))} of ${typeName}`; throw new SchemaError(err); } return obj; } /** * Class with checkable annotations on fields. * This annotation adds the implementation of the `checked` * static method. */ export function Class(opts: {extra?: boolean, validate?: boolean} = {}) { return (target: any) => { target.checked = (v: any) => { const cv = checkValue(v, { checker: checkValue, extraAllowed: !!opts.extra, propertyKey: "(root)", type: target, }, ["(root)"]); if (opts.validate) { if (typeof target.validate !== "function") { console.error("target", target); throw Error("invalid Checkable annotion: validate method required"); } // May throw exception target.validate(cv); } return cv; }; return target; }; } /** * Target property must be a Checkable object of the given type. */ export function Value(typeThunk: () => any) { function deco(target: object, propertyKey: string | symbol): void { const chk = getCheckableInfo(target); chk.props.push({ checker: checkValue, propertyKey, typeThunk, }); } return deco; } /** * List of values that match the given annotation. For example, `@Checkable.List(Checkable.String)` is * an annotation for a list of strings. */ export function List(type: any) { const stub = {}; type(stub, "(list-element)"); const elementProp = getCheckableInfo(stub).props[0]; const elementChecker = elementProp.checker; if (!elementChecker) { throw Error("assertion failed"); } function deco(target: object, propertyKey: string | symbol): void { const chk = getCheckableInfo(target); chk.props.push({ checker: checkList, elementChecker, elementProp, propertyKey, }); } return deco; } /** * Map from the key type to value type. Takes two annotations, * one for the key type and one for the value type. */ export function Map(keyType: any, valueType: any) { const keyStub = {}; keyType(keyStub, "(map-key)"); const keyProp = getCheckableInfo(keyStub).props[0]; if (!keyProp) { throw Error("assertion failed"); } const valueStub = {}; valueType(valueStub, "(map-value)"); const valueProp = getCheckableInfo(valueStub).props[0]; if (!valueProp) { throw Error("assertion failed"); } function deco(target: object, propertyKey: string | symbol): void { const chk = getCheckableInfo(target); chk.props.push({ checker: checkMap, keyProp, propertyKey, valueProp, }); } return deco; } /** * Makes another annotation optional, for example `@Checkable.Optional(Checkable.Number)`. */ export function Optional(type: (target: object, propertyKey: string | symbol) => void | any) { const stub = {}; type(stub, "(optional-element)"); const elementProp = getCheckableInfo(stub).props[0]; const elementChecker = elementProp.checker; if (!elementChecker) { throw Error("assertion failed"); } function deco(target: object, propertyKey: string | symbol): void { const chk = getCheckableInfo(target); chk.props.push({ checker: checkOptional, elementChecker, elementProp, optional: true, propertyKey, }); } return deco; } /** * Target property must be a number. */ export function Number(): (target: object, propertyKey: string | symbol) => void { const deco = (target: object, propertyKey: string | symbol) => { const chk = getCheckableInfo(target); chk.props.push({checker: checkNumber, propertyKey}); }; return deco; } /** * Target property must be an arbitary object. */ export function AnyObject(): (target: object, propertyKey: string | symbol) => void { const deco = (target: object, propertyKey: string | symbol) => { const chk = getCheckableInfo(target); chk.props.push({ checker: checkAnyObject, propertyKey, }); }; return deco; } /** * Target property can be anything. * * Not useful by itself, but in combination with higher-order annotations * such as List or Map. */ export function Any(): (target: object, propertyKey: string | symbol) => void { const deco = (target: object, propertyKey: string | symbol) => { const chk = getCheckableInfo(target); chk.props.push({ checker: checkAny, optional: true, propertyKey, }); }; return deco; } /** * Target property must be a string. */ export function String( stringChecker?: (s: string) => boolean): (target: object, propertyKey: string | symbol, ) => void { const deco = (target: object, propertyKey: string | symbol) => { const chk = getCheckableInfo(target); chk.props.push({ checker: checkString, propertyKey, stringChecker }); }; return deco; } /** * Target property must be a boolean value. */ export function Boolean(): (target: object, propertyKey: string | symbol) => void { const deco = (target: object, propertyKey: string | symbol) => { const chk = getCheckableInfo(target); chk.props.push({ checker: checkBoolean, propertyKey }); }; return deco; } }