import * as Joi from "joi"; import * as _ from "lodash"; import mongoose from "mongoose"; import { config } from "./config"; import { IDocumentValidator } from "./IDocumentValidator"; export interface IDocumentValidatorOptions { without?: { [name: string]: string[] | string }; with?: { [name: string]: string[] | string }; or?: Array>; and?: Array>; nand?: Array>; xor?: Array>; } export class DocumentValidator implements IDocumentValidator { private readonly schema: Joi.ObjectSchema; private readonly mongooseSchema: mongoose.Schema; private readonly options: IDocumentValidatorOptions; constructor( schema: mongoose.Schema, options: IDocumentValidatorOptions = {} ) { this.mongooseSchema = schema; this.options = options; const map = this.build(schema); this.schema = this.createSchema(map, options); } public async validate(document: T) { let obj = document.toObject({ versionKey: false }); obj = this.prepareObject(obj); const result: any = Joi.validate(obj, this.schema, { abortEarly: false, convert: false }); _.each(this.mongooseSchema.obj, (def: any, fieldName: string) => { if (def.immutable && !document.isNew && document.isModified(fieldName)) { if (!result.error) { result.error = { details: [] }; } result.error.details.push({ message: `"${fieldName}" can not be changed`, path: [fieldName], type: "any.immutable", context: { key: fieldName, label: fieldName } }); } }); if (result.error) { throw config.createValidationError(result); } } private prepareObject(obj: any) { _.each(obj, (value: any, field: string) => { if (value instanceof mongoose.Types.ObjectId) { obj[field] = value.toString(); } else if (_.isArray(value)) { obj[field] = value.map(item => { if (item instanceof mongoose.Types.ObjectId) { return item.toString(); } else if (_.isObject(item)) { return this.prepareObject(item); } else { return item; } }); } else if (_.isObject(value)) { obj[field] = this.prepareObject(value); } }); return obj; } private createSchema( map: { [name: string]: Joi.Schema }, options: IDocumentValidatorOptions ) { let schema = Joi.object() .keys(map) .unknown(true); ["and", "nand", "xor", "or"].forEach(fn => { if ((options as any)[fn]) { _.each((options as any)[fn], args => { schema = (schema as any)[fn](...args); }); } }); ["with", "without"].forEach(fn => { if ((options as any)[fn]) { _.each((options as any)[fn], (fields, name) => { schema = (schema as any)[fn](name, fields); }); } }); return schema; } private build(schema: mongoose.Schema) { return this.buildFromDef(schema.obj); } private buildFromDef(obj: any) { const map: { [name: string]: Joi.Schema } = { _id: this.buildObjectIdField().optional() }; _.each(obj, (def: any, fieldName: string) => { map[fieldName] = this.buildFieldFromDef(def); }); return map; } private buildFieldFromDef(def: any): any { let field; if (def.type === Number) { field = this.buildNumberField(def); } else if (def.type === String) { field = this.buildStringField(def); } else if (def.type === mongoose.Schema.Types.Mixed) { field = Joi.any(); } else if (def.type === Boolean) { field = Joi.boolean(); } else if (def.type === Date) { field = Joi.date(); } else if (def.type === mongoose.Schema.Types.ObjectId) { field = this.buildObjectIdField(); } else if (_.isArray(def.type)) { field = this.buildArrayField(def); } else if (def.type instanceof mongoose.Schema) { field = this.buildSchemaField(def); } else if (_.isObject(def.type)) { field = this.buildObjectField(def); } else { throw new Error("Unsupported type"); } if (def.required) { field = field.required(); } else { field = field.allow(null); } return field; } private buildObjectIdField() { return Joi.string() .alphanum() .length(24); } private buildNumberField(def: any) { let field = Joi.number(); if (def.enum) { field = field.valid(...def.enum); } if (def.integer) { field = field.integer(); } if (def.positive) { field = field.positive(); } if (def.negative) { field = field.negative(); } if (def.max) { field = field.max(def.max); } if (def.min) { field = field.min(def.min); } if (def.greater) { field = field.greater(def.greater); } if (def.less) { field = field.less(def.less); } if (def.precision) { field = field.precision(def.precision); } if (def.multiple) { field = field.multiple(def.multiple); } return field; } private buildStringField(def: any) { let field = Joi.string(); if (def.email) { field = field.email({ minDomainAtoms: 2 }); } if (def.creditCard) { field = field.creditCard(); } if (def.alphanum) { field = field.alphanum(); } if (def.token) { field = field.token(); } if (def.ip) { field = field.ip(); } if (def.uri) { field = field.uri(); } if (def.enum) { field = field.valid(...def.enum); } if (def.minlength) { field = field.min(def.minlength); } if (def.maxlength) { field = field.max(def.maxlength); } if (def.match) { field = field.regex(def.match); } return field; } private buildObjectField(def: any) { const type = def.type; const map = this.buildFromDef(type); return Joi.object() .keys(map) .unknown(true); } private buildSchemaField(def: any) { const type = def.type.obj; const map = this.buildFromDef(type); return Joi.object() .keys(map) .unknown(true); } private buildArrayField(def: any) { const type = def.type[0]; let map; if (this.isNativeType(type)) { map = this.buildFieldFromDef({ type }); } else if (this.isNativeType(type.type)) { map = this.buildFieldFromDef(type); } else if (type instanceof mongoose.Schema) { map = this.buildFromDef(type.obj); } else { map = this.buildFromDef(type); } return Joi.array().items(map); } private isNativeType(type: any) { return ( type === String || type === Number || type === Date || type === Boolean || type === mongoose.Schema.Types.ObjectId ); } }