492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
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<V> {
|
|
public static async validate(
|
|
validationMap: { [p: string]: Validator<unknown> },
|
|
body: { [p: string]: unknown },
|
|
): Promise<void> {
|
|
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<V>[] = [];
|
|
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<void> {
|
|
const bag = new ValidationBag<V>();
|
|
|
|
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<V> = 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<V> {
|
|
this.validationAttributes.push('required');
|
|
|
|
this.addStep({
|
|
verifyStep: val => val !== undefined,
|
|
throw: () => new UndefinedValueValidationError(),
|
|
isFormat: true,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
public acceptUndefined(alsoAcceptEmptyString: boolean = false): Validator<V> {
|
|
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<V> {
|
|
this.addStep({
|
|
verifyStep: val => val === other,
|
|
throw: () => new BadValueValidationError(other),
|
|
isFormat: true,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
public sameAs(otherName?: string, other?: V): Validator<V> {
|
|
this.addStep({
|
|
verifyStep: val => val === other,
|
|
throw: () => new DifferentThanError(otherName),
|
|
isFormat: true,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
public regexp(regexp: RegExp): Validator<V> {
|
|
this.validationAttributes.push(`pattern="${regexp}"`);
|
|
this.addStep({
|
|
verifyStep: val => regexp.test(<string><unknown>val),
|
|
throw: () => new InvalidFormatValidationError(),
|
|
isFormat: true,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
public length(length: number): Validator<V> {
|
|
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<V> {
|
|
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<V> {
|
|
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<V> {
|
|
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<V> {
|
|
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<V> {
|
|
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<M extends Model>(
|
|
model: M | ModelType<M>,
|
|
foreignKey?: string,
|
|
querySupplier?: () => ModelQuery<M>,
|
|
): Validator<V> {
|
|
this.addStep({
|
|
verifyStep: async (val, thingName, c) => {
|
|
if (!foreignKey) foreignKey = thingName;
|
|
let query: ModelQuery<M>;
|
|
if (querySupplier) {
|
|
query = querySupplier();
|
|
} else {
|
|
query = (model instanceof Model ? <ModelType<M>>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<Model>, foreignKey?: string): Validator<V> {
|
|
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<V>) {
|
|
this.steps.push(step);
|
|
}
|
|
|
|
public getValidationAttributes(): string[] {
|
|
return this.validationAttributes;
|
|
}
|
|
|
|
public step(step: number): Validator<V> {
|
|
this.validationAttributes.push(`step="${step}"`);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
interface ValidationStep<V> {
|
|
interrupt?: (val?: V) => boolean;
|
|
|
|
verifyStep(val: V | undefined, thingName: string, connection?: Connection): boolean | Promise<boolean>;
|
|
|
|
throw: ((val?: V) => ValidationError<V>) | null;
|
|
|
|
readonly isFormat: boolean;
|
|
}
|
|
|
|
export class ValidationBag<V> extends Error {
|
|
private readonly errors: ValidationError<V>[] = [];
|
|
|
|
public addMessage(err: ValidationError<V>): void {
|
|
if (!err.thingName) throw new Error('Null thing name');
|
|
this.errors.push(err);
|
|
}
|
|
|
|
public addBag(otherBag: ValidationBag<V>): void {
|
|
for (const error of otherBag.errors) {
|
|
this.errors.push(error);
|
|
}
|
|
}
|
|
|
|
public hasMessages(): boolean {
|
|
return this.errors.length > 0;
|
|
}
|
|
|
|
public getMessages(): { [p: string]: ValidationError<V> } {
|
|
const messages: { [p: string]: ValidationError<V> } = {};
|
|
for (const err of this.errors) {
|
|
messages[err.thingName || 'unknown'] = {
|
|
name: err.name,
|
|
message: err.message,
|
|
value: err.value,
|
|
};
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
public getErrors(): ValidationError<V>[] {
|
|
return this.errors;
|
|
}
|
|
}
|
|
|
|
export class ValidationError<V> extends Error {
|
|
public rawValueToHuman?: (val: V) => string;
|
|
public thingName?: string;
|
|
public value?: V;
|
|
|
|
public get name(): string {
|
|
return this.constructor.name;
|
|
}
|
|
}
|
|
|
|
export class BadLengthValidationError<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
public get message(): string {
|
|
return `"${this.value}" is not a valid ${this.thingName}.`;
|
|
}
|
|
}
|
|
|
|
export class UndefinedValueValidationError<V> extends ValidationError<V> {
|
|
public get message(): string {
|
|
return `${this.thingName} is required.`;
|
|
}
|
|
}
|
|
|
|
export class AlreadyExistsValidationError<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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<V> extends ValidationError<V> {
|
|
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';
|
|
}
|