mage-validator
Version:
Validation utility for MAGE user commands & topics (TypeScript)
248 lines (203 loc) • 6.69 kB
text/typescript
import * as mage from 'mage'
import * as classTransformer from 'class-transformer'
import * as classValidator from 'class-validator'
import { ValidatedTopic } from './classes'
import { crash, ValidationError } from './errors'
const deepIterator = require('deep-iterator').default
const functionArguments = require('function-arguments')
/**
* usercommand.execute function signature
*/
type IExecuteFunction = <T>(state: mage.core.IState, ...args: any[]) => Promise<T>
/**
* Validate function type (currently used for MapOf)
*/
export type ValidateFunction = (key: string, value: any) => void
// Expose everything from class-validator and class-transformer
export * from 'class-transformer'
export * from 'class-validator'
/**
* wrap the class' static create method
*
* @param target
* @param key
* @param childrenType
* @param type
*/
function wrapCreate(target: any, key: string, childrenType: any, validateFunction?: ValidateFunction, mapType?: any) {
const { create } = target
target.create = async (
state: mage.core.IState,
index: mage.archivist.IArchivistIndex,
data?: any
) => {
const instance = await create.call(target, state, index, data)
const map = mapType ? new mapType() : {}
if (!instance[key]) {
return instance
}
for (const [subkey, value] of Object.entries(instance[key])) {
map[subkey] = Object.assign(new childrenType(), value)
}
instance[key] = map
return instance
}
const { validate } = target.prototype
target.prototype.validate = async function (errorMessage?: string, code?: string) {
await validate.call(this, errorMessage, code)
let errors: any[] = []
if (!this[key]) {
return
}
for (const [subkey, value] of Object.entries(this[key])) {
errors = errors.concat(await classValidator.validate(value))
if (!validateFunction) {
continue
}
try {
validateFunction(subkey, value)
} catch (error) {
error.message = `Validation error on ${key}.${subkey}: ${error.message}`
const validationError = new classValidator.ValidationError()
validationError.target = target
validationError.property = key
validationError.value = value
validationError.constraints = {
mapOf: error.message
}
errors.push(Object.assign(validationError, error))
}
}
if (errors.length > 0) {
this.raiseValidationError(errors, errorMessage, code)
}
}
}
/**
*
* @param type
*/
export function MapOf(type: any, validateFunction?: ValidateFunction) {
return (target: any, key?: string) => {
if (!key) {
target.isMapOf = { type, validateFunction }
} else {
wrapCreate(target.constructor, key, type, validateFunction)
}
}
}
/**
* Wrap @Type from class-transformer
*
* @param type The type to configure
*/
export function Type(type: any) {
if (type.isMapOf) {
const { type: childrenType, validateFunction } = type.isMapOf
return (target: any, key: string) => wrapCreate(target.constructor, key, childrenType, validateFunction, type)
}
if (type.prototype) {
return classTransformer.Type(/* istanbul ignore next */ () => type)
}
return classTransformer.Type(type)
}
/**
* Validate ValidatedObjects and throw an error if the data is invalid.
*
* @param message
* @param code
* @param state
* @param parsedData
* @param receivedData
*/
async function validateObject(message: string, code: string, state: any, parsedData: any, receivedData?: any) {
const errors = await classValidator.validate(parsedData)
if (errors.length > 0) {
throw new ValidationError(message, code, {
actorId: state.actorId,
userCommand: state.description,
receivedData,
parsedData
}, errors)
}
return parsedData
}
/**
* Validate the output of an `execute` call of an user command.
*
* @param value
*/
async function validateOutput(state: mage.core.IState, value: any) {
const type = typeof value
if (type !== 'object') {
return
}
if (!Array.isArray(value)) {
return validateObject('Invalid user command return value', 'server', state, value)
}
for (const val of value) {
await validateOutput(state, val)
}
}
/**
* @Acl decorator
*
* Protect the user command with the given ACL and defined type validation
*/
export function Acl(...acl: string[]) {
return function (UserCommand: any, key: string) {
if (key !== 'execute') {
throw crash('@validate only works for usercommand.execute functions', {
method: key,
userCommand: UserCommand
})
}
const execute = <IExecuteFunction> UserCommand.execute
const parameterNames = functionArguments(execute)
const types = Reflect.getMetadata('design:paramtypes', UserCommand, key)
if (!types) {
throw crash('Reflect.getMetadata returned null.\
Did you set experimentalDecorators and emitDecoratorMetadata to true in your tsconfig.json ?', {
method: key,
userCommand: UserCommand
})
}
// We extract the state information, since we won't need it
parameterNames.shift()
types.shift()
// We attach additional information to the UserCommand class
UserCommand.acl = acl
UserCommand.params = parameterNames
return {
value: async (state: mage.core.IState, ...args: any[]) => {
// Map user command's parameters
const userCommandData: { [key: string]: any } = {}
args.forEach((arg, pos) => {
const parameterName = parameterNames[pos]
userCommandData[parameterName] = arg
})
// Cast data into a user command instance
const userCommand = classTransformer.plainToClass(UserCommand, userCommandData)
for (const { value } of deepIterator(userCommand)) {
if (value instanceof ValidatedTopic) {
value.setState(state)
const index = (<any> value).index || {}
await value.setIndex(index)
delete (<any> value).index
}
}
// Validate all parameters at once
await validateObject('Invalid user command input', 'invalidInput', state, userCommand, userCommandData)
// Map casted parameters into an array of arguments
const castedArgs = args.map((_arg, pos) => {
const parameterName = parameterNames[pos]
return (<any> userCommand)[parameterName]
})
// Execute the actual user command
const output = await execute(state, ...castedArgs)
await validateOutput(state, output)
return output as any
}
}
}
}