import {Connection} from "mysql"; import {ServerError} from "../HttpError.js"; import Model, {ModelType} from "./Model.js"; import ModelQuery, {WhereTest} from "./ModelQuery.js"; export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; export default class Validator { public static async validate( validationMap: { [p: string]: Validator }, body: { [p: string]: unknown }, ): Promise { const bag = new ValidationBag(); for (const p of Object.keys(validationMap)) { try { await validationMap[p].execute(p, body[p], false); } catch (e) { if (e instanceof ValidationBag) { bag.addBag(e); } else throw e; } } if (bag.hasMessages()) throw bag; } private readonly steps: ValidationStep[] = []; private readonly validationAttributes: string[] = []; private readonly rawValueToHuman?: (val: V) => string; private _min?: number; private _max?: number; public constructor(rawValueToHuman?: (val: V) => string) { this.rawValueToHuman = rawValueToHuman; } /** * @param thingName The name of the thing to validate. * @param value The value to verify. * @param onlyFormat {@code true} to only validate format properties, {@code false} otherwise. * @param connection A connection to use in case of wrapped transactions. */ public async execute( thingName: string, value: V | undefined, onlyFormat: boolean, connection?: Connection, ): Promise { const bag = new ValidationBag(); for (const step of this.steps) { if (onlyFormat && !step.isFormat) continue; let result; try { result = step.verifyStep(value, thingName, connection); if (result instanceof Promise) { result = await result; } } catch (e) { if (e instanceof Error) { throw new ServerError(`An error occurred while validating ${thingName} with value "${value}".`, e); } else { throw e; } } if (result === false && step.throw) { const error: ValidationError = step.throw(); error.rawValueToHuman = this.rawValueToHuman; error.thingName = thingName; error.value = value; bag.addMessage(error); break; } else if (step.interrupt !== undefined && step.interrupt(value)) { break; } } if (bag.hasMessages()) { throw bag; } } public defined(): Validator { this.validationAttributes.push('required'); this.addStep({ verifyStep: val => val !== undefined, throw: () => new UndefinedValueValidationError(), isFormat: true, }); return this; } public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator { this.addStep({ verifyStep: () => true, throw: null, interrupt: val => { return val === undefined || val === null || alsoAcceptEmptyString && typeof val === 'string' && val.length === 0; }, isFormat: true, }); return this; } public equals(other?: V): Validator { this.addStep({ verifyStep: val => val === other, throw: () => new BadValueValidationError(other), isFormat: true, }); return this; } public sameAs(otherName?: string, other?: V): Validator { this.addStep({ verifyStep: val => val === other, throw: () => new DifferentThanError(otherName), isFormat: true, }); return this; } public regexp(regexp: RegExp): Validator { this.validationAttributes.push(`pattern="${regexp}"`); this.addStep({ verifyStep: val => regexp.test(val), throw: () => new InvalidFormatValidationError(), isFormat: true, }); return this; } public length(length: number): Validator { this.addStep({ verifyStep: val => isLenghtable(val) && val.length === length, throw: () => new BadLengthValidationError(length), isFormat: true, }); return this; } /** * @param minLength included */ public minLength(minLength: number): Validator { this.addStep({ verifyStep: val => isLenghtable(val) && val.length >= minLength, throw: () => new TooShortError(minLength), isFormat: true, }); return this; } /** * @param maxLength included */ public maxLength(maxLength: number): Validator { this.addStep({ verifyStep: val => isLenghtable(val) && val.length <= maxLength, throw: () => new TooLongError(maxLength), isFormat: true, }); return this; } /** * @param minLength included * @param maxLength included */ public between(minLength: number, maxLength: number): Validator { this.addStep({ verifyStep: val => { return isLenghtable(val) && val.length >= minLength && val.length <= maxLength; }, throw: () => new BadLengthValidationError(minLength, maxLength), isFormat: true, }); return this; } /** * @param min included */ public min(min: number): Validator { this.validationAttributes.push(`min="${min}"`); this._min = min; this.addStep({ verifyStep: val => { return typeof val === 'number' && val >= min; }, throw: () => new OutOfRangeValidationError(this._min, this._max), isFormat: true, }); return this; } /** * @param max included */ public max(max: number): Validator { this.validationAttributes.push(`max="${max}"`); this._max = max; this.addStep({ verifyStep: val => { return typeof val === 'number' && val <= max; }, throw: () => new OutOfRangeValidationError(this._min, this._max), isFormat: true, }); return this; } public unique( model: M | ModelType, foreignKey?: string, querySupplier?: () => ModelQuery, ): Validator { this.addStep({ verifyStep: async (val, thingName, c) => { if (!foreignKey) foreignKey = thingName; let query: ModelQuery; if (querySupplier) { query = querySupplier(); } else { query = (model instanceof Model ? >model.constructor : model).select(''); } query.where(foreignKey, val); if (model instanceof Model && typeof model.id === 'number') query = query.where('id', model.id, WhereTest.NE); return (await query.execute(c)).results.length === 0; }, throw: () => new AlreadyExistsValidationError(model.table), isFormat: false, }); return this; } public exists(modelType: ModelType, foreignKey?: string): Validator { this.addStep({ verifyStep: async (val, thingName, c) => { const results = await modelType.select('') .where(foreignKey !== undefined ? foreignKey : thingName, val) .execute(c); return results.results.length >= 1; }, throw: () => new UnknownRelationValidationError(modelType.table, foreignKey), isFormat: false, }); return this; } private addStep(step: ValidationStep) { this.steps.push(step); } public getValidationAttributes(): string[] { return this.validationAttributes; } public step(step: number): Validator { this.validationAttributes.push(`step="${step}"`); return this; } } interface ValidationStep { interrupt?: (val?: V) => boolean; verifyStep(val: V | undefined, thingName: string, connection?: Connection): boolean | Promise; throw: ((val?: V) => ValidationError) | null; readonly isFormat: boolean; } export class ValidationBag extends Error { private readonly errors: ValidationError[] = []; public addMessage(err: ValidationError): void { if (!err.thingName) throw new Error('Null thing name'); this.errors.push(err); } public addBag(otherBag: ValidationBag): void { for (const error of otherBag.errors) { this.errors.push(error); } } public hasMessages(): boolean { return this.errors.length > 0; } public getMessages(): { [p: string]: ValidationError } { const messages: { [p: string]: ValidationError } = {}; for (const err of this.errors) { messages[err.thingName || 'unknown'] = { name: err.name, message: err.message, value: err.value, }; } return messages; } public getErrors(): ValidationError[] { return this.errors; } } export class ValidationError extends Error { public rawValueToHuman?: (val: V) => string; public thingName?: string; public value?: V; public get name(): string { return this.constructor.name; } } export class BadLengthValidationError extends ValidationError { private readonly expectedLength: number; private readonly maxLength?: number; public constructor(expectedLength: number, maxLength?: number) { super(); this.expectedLength = expectedLength; this.maxLength = maxLength; } public get message(): string { return `${this.thingName} expected length: ${this.expectedLength}${this.maxLength !== undefined ? ` to ${this.maxLength}` : ''}; ` + `actual length: ${isLenghtable(this.value) && this.value.length}.`; } } export class TooShortError extends ValidationError { private readonly minLength: number; public constructor(minLength: number) { super(); this.minLength = minLength; } public get message(): string { return `${this.thingName} must be at least ${this.minLength} characters.`; } } export class TooLongError extends ValidationError { private readonly maxLength: number; public constructor(maxLength: number) { super(); this.maxLength = maxLength; } public get message(): string { return `${this.thingName} must be at most ${this.maxLength} characters.`; } } export class BadValueValidationError extends ValidationError { private readonly expectedValue: V; public constructor(expectedValue: V) { super(); this.expectedValue = expectedValue; } public get message(): string { let expectedValue: string = String(this.expectedValue); let actualValue: string = String(this.value); if (this.rawValueToHuman && this.value) { expectedValue = this.rawValueToHuman(this.expectedValue); actualValue = this.rawValueToHuman(this.value); } return `Expected: ${expectedValue}; got: ${actualValue}.`; } } export class DifferentThanError extends ValidationError { private readonly otherName?: string; public constructor(otherName?: string) { super(); this.otherName = otherName; } public get message(): string { return `This should be the same as ${this.otherName}.`; } } export class OutOfRangeValidationError extends ValidationError { private readonly min?: number; private readonly max?: number; public constructor(min?: number, max?: number) { super(); this.min = min; this.max = max; } public get message(): string { if (this.min === undefined) { return `${this.thingName} must be at most ${this.max}`; } else if (this.max === undefined) { return `${this.thingName} must be at least ${this.min}`; } let min: string = String(this.min); let max: string = String(this.max); if (this.rawValueToHuman) { min = this.rawValueToHuman(this.min as unknown as V); max = this.rawValueToHuman(this.max as unknown as V); } return `${this.thingName} must be between ${min} and ${max}.`; } } export class InvalidFormatValidationError extends ValidationError { public get message(): string { return `"${this.value}" is not a valid ${this.thingName}.`; } } export class UndefinedValueValidationError extends ValidationError { public get message(): string { return `${this.thingName} is required.`; } } export class AlreadyExistsValidationError extends ValidationError { private readonly table: string; public constructor(table: string) { super(); this.table = table; } public get message(): string { return `${this.thingName} already exists in ${this.table}.`; } } export class UnknownRelationValidationError extends ValidationError { private readonly table: string; private readonly foreignKey?: string; public constructor(table: string, foreignKey?: string) { super(); this.table = table; this.foreignKey = foreignKey; } public get message(): string { return `${this.thingName}=${this.value} relation was not found in ${this.table}${this.foreignKey !== undefined ? `.${this.foreignKey}` : ''}.`; } } export class FileError extends ValidationError { private readonly _message: string; public constructor(message: string) { super(); this._message = message; } public get message(): string { return `${this._message}`; } } export type Lengthable = { length: number, }; export function isLenghtable(value: unknown): value is Lengthable { return value !== undefined && value !== null && typeof (value as Lengthable).length === 'number'; }