From 8cf19792a672df820873a1ef9b89a5fa7682ebab Mon Sep 17 00:00:00 2001 From: garrettmills Date: Mon, 17 Jan 2022 15:57:40 -0600 Subject: [PATCH] Start routing and pipeline rewrite --- src/auth/index.ts | 4 - src/auth/ui/basic/BasicLoginController.ts | 143 ---------- src/auth/ui/basic/BasicLoginFormRequest.ts | 24 -- src/auth/ui/basic/BasicRegisterFormRequest.ts | 25 -- src/di/constructable.ts | 11 + src/di/index.ts | 1 + src/forms/.gitkeep | 0 src/forms/FormRequest.ts | 62 ---- src/forms/Validator.ts | 109 -------- src/forms/index.ts | 9 - src/forms/middleware.ts | 34 --- src/forms/rules/arrays.ts | 150 ---------- src/forms/rules/inference.ts | 80 ------ src/forms/rules/numeric.ts | 210 -------------- src/forms/rules/presence.ts | 191 ------------- src/forms/rules/provided/DateValidator.ts | 31 -- src/forms/rules/rules.ts | 5 - src/forms/rules/strings.ts | 264 ------------------ src/forms/rules/types.ts | 85 ------ src/forms/templates/form.ts | 44 --- src/forms/unit/Forms.ts | 18 -- src/http/routing/Route.ts | 14 +- src/http/routing/Route2.ts | 209 ++++++++++++++ src/orm/model/Model.ts | 11 +- src/orm/schema/PostgresSchema.ts | 52 ++-- src/orm/schema/TableBuilder.ts | 35 ++- src/service/Routing.ts | 6 +- src/util/collection/AsyncCollection.ts | 17 +- src/util/collection/Collection.ts | 11 +- src/util/support/Messages.ts | 13 +- src/util/support/Pipe.ts | 136 ++++----- src/util/support/path.ts | 15 +- src/util/support/types.ts | 26 ++ src/validation/Validator.ts | 75 +++++ 34 files changed, 468 insertions(+), 1652 deletions(-) delete mode 100644 src/auth/ui/basic/BasicLoginController.ts delete mode 100644 src/auth/ui/basic/BasicLoginFormRequest.ts delete mode 100644 src/auth/ui/basic/BasicRegisterFormRequest.ts create mode 100644 src/di/constructable.ts delete mode 100644 src/forms/.gitkeep delete mode 100644 src/forms/FormRequest.ts delete mode 100644 src/forms/Validator.ts delete mode 100644 src/forms/index.ts delete mode 100644 src/forms/middleware.ts delete mode 100644 src/forms/rules/arrays.ts delete mode 100644 src/forms/rules/inference.ts delete mode 100644 src/forms/rules/numeric.ts delete mode 100644 src/forms/rules/presence.ts delete mode 100644 src/forms/rules/provided/DateValidator.ts delete mode 100644 src/forms/rules/rules.ts delete mode 100644 src/forms/rules/strings.ts delete mode 100644 src/forms/rules/types.ts delete mode 100644 src/forms/templates/form.ts delete mode 100644 src/forms/unit/Forms.ts create mode 100644 src/http/routing/Route2.ts diff --git a/src/auth/index.ts b/src/auth/index.ts index 6db3b87..2474f0e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -14,8 +14,4 @@ export * from './event/UserFlushedEvent' export * from './repository/orm/ORMUser' export * from './repository/orm/ORMUserRepository' -export * from './ui/basic/BasicRegisterFormRequest' -export * from './ui/basic/BasicLoginFormRequest' -export * from './ui/basic/BasicLoginController' - export * from './config' diff --git a/src/auth/ui/basic/BasicLoginController.ts b/src/auth/ui/basic/BasicLoginController.ts deleted file mode 100644 index a9dca90..0000000 --- a/src/auth/ui/basic/BasicLoginController.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {Controller} from '../../../http/Controller' -import {Inject, Injectable} from '../../../di' -import {SecurityContext} from '../../context/SecurityContext' -import {Session} from '../../../http/session/Session' -import {ResponseFactory} from '../../../http/response/ResponseFactory' -import {redirectToGet, view} from '../../../http/response/ViewResponseFactory' -import {Routing} from '../../../service/Routing' -import {ResponseObject, Route} from '../../../http/routing/Route' -import {BasicLoginFormData, BasicLoginFormRequest} from './BasicLoginFormRequest' -import {Valid, ValidationError} from '../../../forms' -import {BasicRegisterFormData, BasicRegisterFormRequest} from './BasicRegisterFormRequest' -import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError' -import {Request} from '../../../http/lifecycle/Request' - -@Injectable() -export class BasicLoginController extends Controller { - public static routes({ enableRegistration = true } = {}): void { - const controller = (request: Request) => { - return request.make(BasicLoginController) - } - - Route.group('auth', () => { - Route.get('login', (request: Request) => - controller(request).getLogin()) - .pre('@auth:guest') - .alias('@auth.login') - - Route.post('login', (request: Request) => - controller(request).attemptLogin()) - .pre('@auth:guest') - .alias('@auth.login.attempt') - - Route.any('logout', (request: Request) => - controller(request).attemptLogout()) - .pre('@auth:required') - .alias('@auth.logout') - - if ( enableRegistration ) { - Route.get('register', (request: Request) => - controller(request).getRegistration()) - .pre('@auth:guest') - .alias('@auth.register') - - Route.post('register', (request: Request) => - controller(request).attemptRegister()) - .pre('@auth:guest') - .alias('@auth.register.attempt') - } - }).pre('@auth:web') - } - - @Inject() - protected readonly security!: SecurityContext - - @Inject() - protected readonly session!: Session - - @Inject() - protected readonly routing!: Routing - - public getLogin(): ResponseFactory { - return this.getLoginView() - } - - public getRegistration(): ResponseFactory { - if ( !this.security.repository.supportsRegistration() ) { - return redirectToGet('/') - } - - return this.getRegistrationView() - } - - public async attemptLogin(): Promise { - const form = this.request.make(BasicLoginFormRequest) - - try { - const data: Valid = await form.get() - const user = await this.security.repository.getByIdentifier(data.username) - if ( user && (await user.validateCredential(data.password)) ) { - await this.security.authenticate(user) - - const intention = this.session.get('@extollo:auth.intention', '/') - this.session.forget('@extollo:auth.intention') - return redirectToGet(intention) - } - - return this.getLoginView(['Invalid username/password.']) - } catch (e: unknown) { - if ( e instanceof ValidationError ) { - return this.getLoginView(e.errors.all()) - } - - throw e - } - } - - public async attemptRegister(): Promise { - const form = this.request.make(BasicRegisterFormRequest) - - try { - const data: Valid = await form.get() - const user = await this.security.repository.createFromCredentials(data.username, data.password) - await this.security.authenticate(user) - - const intention = this.session.get('@extollo:auth.intention', '/') - this.session.forget('@extollo:auth.intention') - return redirectToGet(intention) - } catch (e: unknown) { - if ( e instanceof ValidationError ) { - return this.getRegistrationView(e.errors.all()) - } else if ( e instanceof AuthenticatableAlreadyExistsError ) { - return this.getRegistrationView(['A user with that username already exists.']) - } - - throw e - } - } - - public async attemptLogout(): Promise { - await this.security.flush() - return this.getMessageView('You have been logged out.') - } - - protected getRegistrationView(errors?: string[]): ResponseFactory { - return view('@extollo:auth:register', { - formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote, - errors, - }) - } - - protected getLoginView(errors?: string[]): ResponseFactory { - return view('@extollo:auth:login', { - formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote, - errors, - }) - } - - protected getMessageView(message: string): ResponseFactory { - return view('@extollo:auth:message', { - message, - }) - } -} diff --git a/src/auth/ui/basic/BasicLoginFormRequest.ts b/src/auth/ui/basic/BasicLoginFormRequest.ts deleted file mode 100644 index b7ce175..0000000 --- a/src/auth/ui/basic/BasicLoginFormRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {FormRequest, ValidationRules} from '../../../forms' -import {Is, Str} from '../../../forms/rules/rules' -import {Singleton} from '../../../di' - -export interface BasicLoginFormData { - username: string, - password: string, -} - -@Singleton() -export class BasicLoginFormRequest extends FormRequest { - protected getRules(): ValidationRules { - return { - username: [ - Is.required, - Str.lengthMin(1), - ], - password: [ - Is.required, - Str.lengthMin(1), - ], - } - } -} diff --git a/src/auth/ui/basic/BasicRegisterFormRequest.ts b/src/auth/ui/basic/BasicRegisterFormRequest.ts deleted file mode 100644 index b1a1499..0000000 --- a/src/auth/ui/basic/BasicRegisterFormRequest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {FormRequest, ValidationRules} from '../../../forms' -import {Is, Str} from '../../../forms/rules/rules' -import {Singleton} from '../../../di' - -export interface BasicRegisterFormData { - username: string, - password: string, -} - -@Singleton() -export class BasicRegisterFormRequest extends FormRequest { - protected getRules(): ValidationRules { - return { - username: [ - Is.required, - Str.lengthMin(1), - ], - password: [ - Is.required, - Str.lengthMin(1), - Str.confirmed, - ], - } - } -} diff --git a/src/di/constructable.ts b/src/di/constructable.ts new file mode 100644 index 0000000..47c0c88 --- /dev/null +++ b/src/di/constructable.ts @@ -0,0 +1,11 @@ +import {Container} from './Container' +import {TypedDependencyKey} from './types' +import {Pipeline} from '../util' + +export type Constructable = Pipeline + +export function constructable(key: TypedDependencyKey): Constructable { + return new Pipeline( + container => container.make(key), + ) +} diff --git a/src/di/index.ts b/src/di/index.ts index 17d393e..5c10236 100644 --- a/src/di/index.ts +++ b/src/di/index.ts @@ -14,3 +14,4 @@ export * from './types' export * from './decorator/injection' export * from './InjectionAware' +export * from './constructable' diff --git a/src/forms/.gitkeep b/src/forms/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/forms/FormRequest.ts b/src/forms/FormRequest.ts deleted file mode 100644 index ec1d0b4..0000000 --- a/src/forms/FormRequest.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {Container, Injectable, InjectParam} from '../di' -import {Request} from '../http/lifecycle/Request' -import {Valid, ValidationRules} from './rules/types' -import {Validator} from './Validator' -import {AppClass} from '../lifecycle/AppClass' -import {DataContainer} from '../http/lifecycle/Request' - -/** - * Base class for defining reusable validators for request routes. - * If instantiated with a container, it must be a request-level container, - * but the type interface allows any data-container to be used when creating - * manually. - * - * You should mark implementations of this class as singleton to avoid - * re-validating the input data every time it is accessed. - * - * @example - * ```ts - * // Instantiate with the request: - * const data = request.make(MyFormRequest) - * - * // Instantiate with some container: - * const data = new MyFormRequest(someDataContainer) - * ``` - */ -@Injectable() -export abstract class FormRequest extends AppClass { - /** The cached validation result. */ - protected cachedResult?: Valid - - constructor( - @InjectParam(Request) - protected readonly data: DataContainer, - ) { - super() - } - - protected container(): Container { - return (this.data as unknown) as Container - } - - /** - * The validation rules that should be applied to the request to guarantee - * that it contains the given data type. - * @protected - */ - protected abstract getRules(): ValidationRules | Promise - - /** - * Validate and get the request input. Throws a validation error on fail. - * Internally, caches the result after the first validation. So, singleton - * validators will avoid re-processing their rules every time. - */ - public async get(): Promise> { - if ( !this.cachedResult ) { - const validator = > this.make(Validator, await this.getRules()) - this.cachedResult = await validator.validate(this.data.input()) - } - - return this.cachedResult - } -} diff --git a/src/forms/Validator.ts b/src/forms/Validator.ts deleted file mode 100644 index e26155d..0000000 --- a/src/forms/Validator.ts +++ /dev/null @@ -1,109 +0,0 @@ -import {Valid, ValidationResult, ValidationRules, ValidatorFunction, ValidatorFunctionParams} from './rules/types' -import {Messages, ErrorWithContext, dataWalkUnsafe, dataSetUnsafe} from '../util' - -/** - * An error thrown thrown when an object fails its validation. - */ -export class ValidationError extends ErrorWithContext { - constructor( - /** The original input data. */ - public readonly data: unknown, - - /** The validator instance used. */ - public readonly validator: Validator, - - /** Validation error messages, by field. */ - public readonly errors: Messages, - ) { - super('One or more fields were invalid.', { data, - messages: errors.all() }) - } -} - -/** - * A class to validate arbitrary data using functional rules. - */ -export class Validator { - constructor( - /** The rules used to validate input objects. */ - protected readonly rules: ValidationRules, - ) {} - - /** - * Attempt to validate the input data. - * If it is valid, it is type aliased as Valid. - * If it is invalid, a ValidationError is thrown. - * @param data - */ - public async validate(data: unknown): Promise> { - const messages = await this.validateAndGetErrors(data) - if ( messages.any() ) { - throw new ValidationError(data, this, messages) - } - - return data as Valid - } - - /** - * Returns true if the given data is valid and type aliases it as Valid. - * @param data - */ - public async isValid(data: unknown): Promise { - return !(await this.validateAndGetErrors(data)).any() - } - - /** - * Apply the validation rules to the data object and return any error messages. - * @param data - * @protected - */ - protected async validateAndGetErrors(data: unknown): Promise { - const messages = new Messages() - const params: ValidatorFunctionParams = { data } - - for ( const key in this.rules ) { - if ( !Object.prototype.hasOwnProperty.call(this.rules, key) ) { - continue - } - - // This walks over all of the values in the data structure using the nested - // key notation. It's not type-safe, but neither is the original input object - // yet, so it's useful here. - for ( const walkEntry of dataWalkUnsafe(data as any, key) ) { - let [entry, dataKey] = walkEntry // eslint-disable-line prefer-const - const rules = (Array.isArray(this.rules[key]) ? this.rules[key] : [this.rules[key]]) as ValidatorFunction[] - - for ( const rule of rules ) { - const result: ValidationResult = await rule(dataKey, entry, params) - - if ( !result.valid ) { - let errors = ['is invalid'] - - if ( Array.isArray(result.message) && result.message.length ) { - errors = result.message - } else if ( !Array.isArray(result.message) && result.message ) { - errors = [result.message] - } - - for ( const error of errors ) { - if ( !messages.has(dataKey, error) ) { - messages.put(dataKey, error) - } - } - } - - if ( result.valid && result.castValue ) { - entry = result.castValue - data = dataSetUnsafe(dataKey, entry, data as any) - } - - if ( result.stopValidation ) { - break // move on to the next field - } - } - } - } - - return messages - } -} diff --git a/src/forms/index.ts b/src/forms/index.ts deleted file mode 100644 index 5cbff77..0000000 --- a/src/forms/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './rules/types' -export * as Rule from './rules/rules' - -export * from './unit/Forms' - -export * from './Validator' -export * from './FormRequest' - -export * from './middleware' diff --git a/src/forms/middleware.ts b/src/forms/middleware.ts deleted file mode 100644 index eb595e5..0000000 --- a/src/forms/middleware.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {Instantiable} from '../di' -import {FormRequest} from './FormRequest' -import {ValidationError} from './Validator' -import {ResponseObject, RouteHandler} from '../http/routing/Route' -import {Request} from '../http/lifecycle/Request' - -/** - * Builds a middleware function that validates a request's input against - * the given form request class and registers the FormRequest class into - * the request container. - * - * @example - * ```typescript - * Route.group(...).pre(formRequest(MyFormRequestClass)) - * ``` - * - * @param formRequestClass - */ -export function formRequest(formRequestClass: Instantiable>): RouteHandler { - return async function formRequestRouteHandler(request: Request): Promise { - const formRequestInstance = > request.make(formRequestClass) - - try { - await formRequestInstance.get() - request.registerSingletonInstance>(formRequestClass, formRequestInstance) - } catch (e: unknown) { - if ( e instanceof ValidationError ) { - return e.errors.toJSON() - } - - throw e - } - } -} diff --git a/src/forms/rules/arrays.ts b/src/forms/rules/arrays.ts deleted file mode 100644 index 7fccc7a..0000000 --- a/src/forms/rules/arrays.ts +++ /dev/null @@ -1,150 +0,0 @@ -import {ValidationResult, ValidatorFunction} from './types' - -/** Requires the input value to be an array. */ -function is(fieldName: string, inputValue: unknown): ValidationResult { - if ( Array.isArray(inputValue) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be an array', - } -} - -/** Requires the values in the input value array to be distinct. */ -function distinct(fieldName: string, inputValue: unknown): ValidationResult { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && (new Set(inputValue)).size === inputValue.length ) { - return { valid: true } - } - - return { - valid: false, - message: 'must not contain duplicate values', - } -} - -/** - * Builds a validator function that requires the input array to contain the given value. - * @param value - */ -function includes(value: unknown): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && inputValue.includes(value) ) { - return { valid: true } - } - - return { - valid: false, - message: `must include ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input array NOT to contain the given value. - * @param value - */ -function excludes(value: unknown): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && !inputValue.includes(value) ) { - return { valid: true } - } - - return { - valid: false, - message: `must not include ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input array to have exactly `len` many entries. - * @param len - */ -function length(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && inputValue.length === len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be exactly of length ${len}`, - } - } -} - -/** - * Builds a validator function that requires the input array to have at least `len` many entries. - * @param len - */ -function lengthMin(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && inputValue.length >= len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at least length ${len}`, - } - } -} - -/** - * Builds a validator function that requires the input array to have at most `len` many entries. - * @param len - */ -function lengthMax(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - const arr = is(fieldName, inputValue) - if ( !arr.valid ) { - return arr - } - - if ( Array.isArray(inputValue) && inputValue.length <= len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at most length ${len}`, - } - } -} - -export const Arr = { - is, - distinct, - includes, - excludes, - length, - lengthMin, - lengthMax, -} diff --git a/src/forms/rules/inference.ts b/src/forms/rules/inference.ts deleted file mode 100644 index d62435f..0000000 --- a/src/forms/rules/inference.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {infer as inferUtil} from '../../util' -import {ValidationResult} from './types' - -/** Attempt to infer the native type of a string value. */ -function infer(fieldName: string, inputValue: unknown): ValidationResult { - return { - valid: true, - castValue: typeof inputValue === 'string' ? inferUtil(inputValue) : inputValue, - } -} - -/** - * Casts the input value to a boolean. - * Note that this assumes the value may be boolish. The strings "true", "True", - * "TRUE", and "1" evaluate to `true`, while "false", "False", "FALSE", and "0" - * evaluate to `false`. - * @param fieldName - * @param inputValue - */ -function boolean(fieldName: string, inputValue: unknown): ValidationResult { - let castValue = Boolean(inputValue) - - if ( ['true', 'True', 'TRUE', '1'].includes(String(inputValue)) ) { - castValue = true - } - if ( ['false', 'False', 'FALSE', '0'].includes(String(inputValue)) ) { - castValue = false - } - - return { - valid: true, - castValue, - } -} - -/** Casts the input value to a string. */ -function string(fieldName: string, inputValue: unknown): ValidationResult { - return { - valid: true, - castValue: String(inputValue), - } -} - -/** Casts the input value to a number, if it is numerical. Fails otherwise. */ -function numeric(fieldName: string, inputValue: unknown): ValidationResult { - if ( !isNaN(parseFloat(String(inputValue))) ) { - return { - valid: true, - castValue: parseFloat(String(inputValue)), - } - } - - return { - valid: false, - message: 'must be numeric', - } -} - -/** Casts the input value to an integer. Fails otherwise. */ -function integer(fieldName: string, inputValue: unknown): ValidationResult { - if ( !isNaN(parseInt(String(inputValue), 10)) ) { - return { - valid: true, - castValue: parseInt(String(inputValue), 10), - } - } - - return { - valid: false, - message: 'must be an integer', - } -} - -export const Cast = { - infer, - boolean, - string, - numeric, - integer, -} diff --git a/src/forms/rules/numeric.ts b/src/forms/rules/numeric.ts deleted file mode 100644 index 06ee8dc..0000000 --- a/src/forms/rules/numeric.ts +++ /dev/null @@ -1,210 +0,0 @@ -import {ValidationResult, ValidatorFunction} from './types' - -/** - * Builds a validator function that requires the input value to be greater than some value. - * @param value - */ -function greaterThan(value: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( Number(inputValue) > value ) { - return { valid: true } - } - - return { - valid: false, - message: `must be greater than ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input value to be at least some value. - * @param value - */ -function atLeast(value: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( Number(inputValue) >= value ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at least ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input value to be less than some value. - * @param value - */ -function lessThan(value: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( Number(inputValue) < value ) { - return { valid: true } - } - - return { - valid: false, - message: `must be less than ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input value to be at most some value. - * @param value - */ -function atMost(value: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( Number(inputValue) <= value ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at most ${value}`, - } - } -} - -/** - * Builds a validator function that requires the input value to have exactly `num` many digits. - * @param num - */ -function digits(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).replace('.', '').length === num ) { - return { valid: true } - } - - return { - valid: false, - message: `must have exactly ${num} digits`, - } - } -} - -/** - * Builds a validator function that requires the input value to have at least `num` many digits. - * @param num - */ -function digitsMin(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).replace('.', '').length >= num ) { - return { valid: true } - } - - return { - valid: false, - message: `must have at least ${num} digits`, - } - } -} - -/** - * Builds a validator function that requires the input value to have at most `num` many digits. - * @param num - */ -function digitsMax(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).replace('.', '').length <= num ) { - return { valid: true } - } - - return { - valid: false, - message: `must have at most ${num} digits`, - } - } -} - -/** - * Builds a validator function that requires the input value to end with the given number sequence. - * @param num - */ -function ends(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).endsWith(String(num)) ) { - return { valid: true } - } - - return { - valid: false, - message: `must end with "${num}"`, - } - } -} - -/** - * Builds a validator function that requires the input value to begin with the given number sequence. - * @param num - */ -function begins(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).startsWith(String(num)) ) { - return { valid: true } - } - - return { - valid: false, - message: `must begin with "${num}"`, - } - } -} - -/** - * Builds a validator function that requires the input value to be a multiple of the given number. - * @param num - */ -function multipleOf(num: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( parseFloat(String(inputValue)) % num === 0 ) { - return { valid: true } - } - - return { - valid: false, - message: `must be a multiple of ${num}`, - } - } -} - -/** Requires the input value to be even. */ -function even(fieldName: string, inputValue: unknown): ValidationResult { - if ( parseFloat(String(inputValue)) % 2 === 0 ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be even', - } -} - -/** Requires the input value to be odd. */ -function odd(fieldName: string, inputValue: unknown): ValidationResult { - if ( parseFloat(String(inputValue)) % 2 === 0 ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be odd', - } -} - -export const Num = { - greaterThan, - atLeast, - lessThan, - atMost, - digits, - digitsMin, - digitsMax, - ends, - begins, - multipleOf, - even, - odd, -} diff --git a/src/forms/rules/presence.ts b/src/forms/rules/presence.ts deleted file mode 100644 index d1bd4c8..0000000 --- a/src/forms/rules/presence.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {ValidationResult, ValidatorFunction} from './types' -import {UniversalPath} from '../../util' - -/** Requires the given input value to be some form of affirmative boolean. */ -function accepted(fieldName: string, inputValue: unknown): ValidationResult { - if ( ['yes', 'Yes', 'YES', 1, true, 'true', 'True', 'TRUE'].includes(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be accepted', - } -} - -/** Requires the given input value to be some form of boolean. */ -function boolean(fieldName: string, inputValue: unknown): ValidationResult { - const boolish = ['true', 'True', 'TRUE', '1', 'false', 'False', 'FALSE', '0', true, false, 1, 0] - if ( boolish.includes(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be true or false', - } -} - -/** Requires the input value to be of type string. */ -function string(fieldName: string, inputValue: unknown): ValidationResult { - if ( typeof inputValue === 'string' ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be a string', - } -} - -/** Requires the given input value to be present and non-nullish. */ -function required(fieldName: string, inputValue: unknown): ValidationResult { - if ( typeof inputValue !== 'undefined' && inputValue !== null && inputValue !== '' ) { - return { valid: true } - } - - return { - valid: false, - message: 'is required', - stopValidation: true, - } -} - -/** Alias of required(). */ -function present(fieldName: string, inputValue: unknown): ValidationResult { - return required(fieldName, inputValue) -} - -/** Alias of required(). */ -function filled(fieldName: string, inputValue: unknown): ValidationResult { - return required(fieldName, inputValue) -} - -/** Requires the given input value to be absent or nullish. */ -function prohibited(fieldName: string, inputValue: unknown): ValidationResult { - if ( typeof inputValue === 'undefined' || inputValue === null || inputValue === '' ) { - return { valid: true } - } - - return { - valid: false, - message: 'is not allowed', - stopValidation: true, - } -} - -/** Alias of prohibited(). */ -function absent(fieldName: string, inputValue: unknown): ValidationResult { - return prohibited(fieldName, inputValue) -} - -/** Alias of prohibited(). */ -function empty(fieldName: string, inputValue: unknown): ValidationResult { - return prohibited(fieldName, inputValue) -} - -/** - * Builds a validator function that requires the given input to be found in an array of values. - * @param values - */ -function foundIn(values: any[]): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( values.includes(inputValue) ) { - return { valid: true } - } - - return { - valid: false, - message: `must be one of: ${values.join(', ')}`, - } - } -} - -/** - * Builds a validator function that requires the given input NOT to be found in an array of values. - * @param values - */ -function notFoundIn(values: any[]): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( values.includes(inputValue) ) { - return { valid: true } - } - - return { - valid: false, - message: `must be one of: ${values.join(', ')}`, - } - } -} - -/** Requires the input value to be number-like. */ -function numeric(fieldName: string, inputValue: unknown): ValidationResult { - if ( !isNaN(parseFloat(String(inputValue))) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be numeric', - } -} - -/** Requires the given input value to be integer-like. */ -function integer(fieldName: string, inputValue: unknown): ValidationResult { - if ( !isNaN(parseInt(String(inputValue), 10)) && parseInt(String(inputValue), 10) === parseFloat(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be an integer', - } -} - -/** Requires the given input value to be a UniversalPath. */ -function file(fieldName: string, inputValue: unknown): ValidationResult { - if ( inputValue instanceof UniversalPath ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be a file', - } -} - -/** - * A special validator function that marks a field as optional. - * If the value of the field is nullish, no further validation rules will be applied. - * If it is non-nullish, validation will continue. - * @param fieldName - * @param inputValue - */ -function optional(fieldName: string, inputValue: unknown): ValidationResult { - if ( inputValue ?? true ) { - return { - valid: true, - stopValidation: true, - } - } - - return { valid: true } -} - -export const Is = { - accepted, - boolean, - string, - required, - present, - filled, - prohibited, - absent, - empty, - foundIn, - notFoundIn, - numeric, - integer, - file, - optional, -} diff --git a/src/forms/rules/provided/DateValidator.ts b/src/forms/rules/provided/DateValidator.ts deleted file mode 100644 index ac47e7c..0000000 --- a/src/forms/rules/provided/DateValidator.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* -import {Injectable} from '@extollo/di' -import {Validator} from '../Validator' -import {ValidationResult} from "../types"; - -@Injectable() -export class DateValidator extends Validator { - protected names: string[] = [ - 'date', - 'date.after', - 'date.at_least', - 'date.before', - 'date.at_most', - 'date.equals', - 'date.format', - ] - - public matchName(name: string): boolean { - return this.names.includes(name) - } - - validate(fieldName: string, inputValue: any, params: { name: string; params: any }): ValidationResult { - switch ( params.name ) { - - } - - - return { valid: false } - } -} -*/ diff --git a/src/forms/rules/rules.ts b/src/forms/rules/rules.ts deleted file mode 100644 index 541356b..0000000 --- a/src/forms/rules/rules.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { Arr } from './arrays' -export { Cast } from './inference' -export { Num } from './numeric' -export { Is } from './presence' -export { Str } from './strings' diff --git a/src/forms/rules/strings.ts b/src/forms/rules/strings.ts deleted file mode 100644 index b6d3cd7..0000000 --- a/src/forms/rules/strings.ts +++ /dev/null @@ -1,264 +0,0 @@ -import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types' -import {isJSON} from '../../util' - -/** - * String-related validation rules. - */ -const regexes: {[key: string]: RegExp} = { - 'string.is.alpha': /[a-zA-Z]*/, - 'string.is.alpha_num': /[a-zA-Z0-9]*/, - 'string.is.alpha_dash': /[a-zA-Z-]*/, - 'string.is.alpha_score': /[a-zA-Z_]*/, - 'string.is.alpha_num_dash_score': /[a-zA-Z\-_0-9]*/, - 'string.is.email': /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)])/, // eslint-disable-line no-control-regex - 'string.is.ip': /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/, - 'string.is.ip.v4': /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/, - 'string.is.ip.v6': /(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))/, - 'string.is.mime': /^(?=[-a-z]{1,127}\/[-.a-z0-9]{1,127}$)[a-z]+(-[a-z]+)*\/[a-z0-9]+([-.][a-z0-9]+)*$/, - 'string.is.url': /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/, - 'string.is.uuid': /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, -} - -function validateRex(key: string, inputValue: unknown, message: string): ValidationResult { - if ( regexes[key].test(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message, - } -} - -/** Requires the input value to be alphabetical characters only. */ -function alpha(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.alpha', inputValue, 'must be alphabetical only') -} - -/** Requires the input value to be alphanumeric characters only. */ -function alphaNum(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.alpha_num', inputValue, 'must be alphanumeric only') -} - -/** Requires the input value to be alphabetical characters or the "-" character only. */ -function alphaDash(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.alpha_dash', inputValue, 'must be alphabetical and dashes only') -} - -/** Requires the input value to be alphabetical characters or the "_" character only. */ -function alphaScore(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.alpha_score', inputValue, 'must be alphabetical and underscores only') -} - -/** Requires the input value to be alphabetical characters, numeric characters, "-", or "_" only. */ -function alphaNumDashScore(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.alpha_num_dash_score', inputValue, 'must be alphanumeric, dashes, and underscores only') -} - -/** Requires the input value to be a valid RFC email address format. */ -function email(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.email', inputValue, 'must be an email address') -} - -/** Requires the input value to be a valid IPv4 or IPv6 address. */ -function ip(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.ip', inputValue, 'must be a valid IP address') -} - -/** Requires the input value to be a valid IPv4 address. */ -function ipv4(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.ip.v4', inputValue, 'must be a valid IP version 4 address') -} - -/** Requires the input value to be a valid IPv6 address. */ -function ipv6(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.ip.v6', inputValue, 'must be a valid IP version 6 address') -} - -/** Requires the input value to be a valid file MIME type. */ -function mime(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.mime', inputValue, 'must be a valid MIME-type') -} - -/** Requires the input value to be a valid RFC URL format. */ -function url(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.url', inputValue, 'must be a valid URL') -} - -/** Requires the input value to be a valid RFC UUID format. */ -function uuid(fieldName: string, inputValue: unknown): ValidationResult { - return validateRex('string.is.uuid', inputValue, 'must be a valid UUID') -} - -/** - * Builds a validation function that requires the input value to match the given regex. - * @param rex - */ -function regex(rex: RegExp): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( rex.test(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'is not valid', - } - } -} - -/** - * Builds a validation function that requires the input to NOT match the given regex. - * @param rex - */ -function notRegex(rex: RegExp): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( !rex.test(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'is not valid', - } - } -} - -/** - * Builds a validation function that requires the given input to end with the substring. - * @param substr - */ -function ends(substr: string): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).endsWith(substr) ) { - return { valid: true } - } - - return { - valid: false, - message: `must end with "${substr}"`, - } - } -} - -/** - * Builds a validation function that requires the given input to begin with the substring. - * @param substr - */ -function begins(substr: string): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).startsWith(substr) ) { - return { valid: true } - } - - return { - valid: false, - message: `must begin with "${substr}"`, - } - } -} - -/** Requires the input value to be a valid JSON string. */ -function json(fieldName: string, inputValue: unknown): ValidationResult { - if ( isJSON(String(inputValue)) ) { - return { valid: true } - } - - return { - valid: false, - message: 'must be valid JSON', - } -} - -/** - * Builds a validator function that requires the input value to have exactly len many characters. - * @param len - */ -function length(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).length === len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be exactly of length ${len}`, - } - } -} - -/** - * Builds a validator function that requires the input value to have at least len many characters. - * @param len - */ -function lengthMin(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).length >= len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at least length ${len}`, - } - } -} - -/** - * Builds a validator function that requires the input value to have at most len many characters. - * @param len - */ -function lengthMax(len: number): ValidatorFunction { - return (fieldName: string, inputValue: unknown): ValidationResult => { - if ( String(inputValue).length <= len ) { - return { valid: true } - } - - return { - valid: false, - message: `must be at most length ${len}`, - } - } -} - -/** - * Validator function that requires the input value to match a `${field}Confirm` field's value. - * @param fieldName - * @param inputValue - * @param params - */ -function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult { - const confirmedFieldName = `${fieldName}Confirm` - if ( inputValue === params.data[confirmedFieldName] ) { - return { valid: true } - } - - return { - valid: false, - message: `confirmation does not match`, - } -} - -export const Str = { - alpha, - alphaNum, - alphaDash, - alphaScore, - alphaNumDashScore, - email, - ip, - ipv4, - ipv6, - mime, - url, - uuid, - regex, - notRegex, - ends, - begins, - json, - length, - lengthMin, - lengthMax, - confirmed, -} diff --git a/src/forms/rules/types.ts b/src/forms/rules/types.ts deleted file mode 100644 index 2403dd1..0000000 --- a/src/forms/rules/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Additional parameters passed to complex validation functions. - */ -export interface ValidatorFunctionParams { - /** The entire original input data. */ - data: any, -} - -/** - * An interface representing the result of an attempted validation that failed. - */ -export interface ValidationErrorResult { - /** Whether or not the validation succeeded. */ - valid: false - - /** - * The human-readable error message(s) describing the issue. - */ - message?: string | string[] - - /** - * If true, validation of subsequent fields will stop. - */ - stopValidation?: boolean -} - -/** - * An interface representing the result of an attempted validation that succeeded. - */ -export interface ValidationSuccessResult { - /** Whether or not the validation succeeded. */ - valid: true - - /** - * If the value was cast to a different type, or inferred, as a result of this validation, - * provide it here. It will replace the input string as the value of the field in the form. - */ - castValue?: any - - /** - * If true, validation of subsequent fields will stop. - */ - stopValidation?: boolean -} - -/** All possible results of an attempted validation. */ -export type ValidationResult = ValidationErrorResult | ValidationSuccessResult - -/** A validator function that takes only the field key and the object value. */ -export type SimpleValidatorFunction = (fieldName: string, inputValue: any) => ValidationResult | Promise - -/** A validator function that takes the field key, the object value, and an object of contextual params. */ -export type ComplexValidatorFunction = (fieldName: string, inputValue: any, params: ValidatorFunctionParams) => ValidationResult | Promise - -/** Useful type alias for all allowed validator function signatures. */ -export type ValidatorFunction = SimpleValidatorFunction | ComplexValidatorFunction - -/** - * A set of validation rules that are applied to input objects on validators. - * - * The keys of this object are deep-nested keys and can be used to validate - * nested properties. - * - * For example, the key "user.links.*.url" refers to the "url" property of all - * objects in the "links" array on the "user" object on: - * - * ```json - * { - * "user": { - * "links": [ - * { - * "url": "..." - * }, - * { - * "url": "..." - * } - * ] - * } - * } - * ``` - */ -export type ValidationRules = {[key: string]: ValidatorFunction | ValidatorFunction[]} - -/** A type alias denoting that a particular type has been validated. */ -export type Valid = T diff --git a/src/forms/templates/form.ts b/src/forms/templates/form.ts deleted file mode 100644 index 24a8277..0000000 --- a/src/forms/templates/form.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {Template} from '../../cli' - -const templateForm: Template = { - name: 'form', - fileSuffix: '.form.ts', - description: 'Create a new form request validator', - baseAppPath: ['http', 'forms'], - render(name: string) { - return `import {Injectable, FormRequest, ValidationRules, Rule} from '@extollo/lib' - -/** - * ${name} object - * ---------------------------- - * This is the object interface that is guaranteed when - * all of the validation rules below pass. - */ -export interface ${name}Form { - -} - -/** - * ${name}Request validator - * ---------------------------- - * Request validator that defines the rules needed to guarantee - * that a request's input conforms to the interface defined above. - */ -@Injectable() -export class ${name}FormRequest extends FormRequest<${name}Form> { - /** - * The validation rules that should be applied to the various - * request input fields. - * @protected - */ - protected getRules(): ValidationRules { - return { - - } - } -} -` - }, -} - -export { templateForm } diff --git a/src/forms/unit/Forms.ts b/src/forms/unit/Forms.ts deleted file mode 100644 index f3c0e3c..0000000 --- a/src/forms/unit/Forms.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {Singleton, Inject} from '../../di' -import {CommandLine} from '../../cli' -import {templateForm} from '../templates/form' -import {Unit} from '../../lifecycle/Unit' -import {Logging} from '../../service/Logging' - -@Singleton() -export class Forms extends Unit { - @Inject() - protected readonly cli!: CommandLine - - @Inject() - protected readonly logging!: Logging - - public async up(): Promise { - this.cli.registerTemplate(templateForm) - } -} diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 7327f62..07a3fd5 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -11,6 +11,7 @@ import {Controller} from '../Controller' import {Middlewares} from '../../service/Middlewares' import {Middleware} from './Middleware' import {Config} from '../../service/Config' +import {Validator} from '../../validation/Validator' /** * Type alias for an item that is a valid response object, or lack thereof. @@ -66,7 +67,7 @@ export class Route extends AppClass { } /** - * Load and compile all of the registered routes and their groups, accounting + * Load and compile all the registered routes and their groups, accounting * for nested groups and resolving handlers. * * This function attempts to resolve the route handlers ahead of time to cache @@ -215,6 +216,8 @@ export class Route extends AppClass { /** Pre-compiled route handler for the main route handler for this route. */ protected compiledPostflight?: ResolvedRouteHandler[] + protected validator?: Validator + /** Programmatic aliases of this route. */ public aliases: string[] = [] @@ -387,6 +390,15 @@ export class Route extends AppClass { return this } + input(validator: Validator): this { + if ( !this.validator ) { + // + } + + this.validator = validator + return this + } + /** Prefix the route's path with the given prefix, normalizing `/` characters. */ private prepend(prefix: string): this { if ( !prefix.endsWith('/') ) { diff --git a/src/http/routing/Route2.ts b/src/http/routing/Route2.ts new file mode 100644 index 0000000..f5dff66 --- /dev/null +++ b/src/http/routing/Route2.ts @@ -0,0 +1,209 @@ +import {Collection, Either, PrefixTypeArray} from '../../util' +import {ResponseFactory} from '../response/ResponseFactory' +import {HTTPMethod, Request} from '../lifecycle/Request' +import {TypedDependencyKey, constructable, Constructable, Instantiable} from '../../di' +import {Middleware} from './Middleware' + +/** + * Type alias for an item that is a valid response object, or lack thereof. + */ +export type ResponseObject = ResponseFactory | string | number | void | any | Promise + +/** + * Type alias for a function that applies a route handler to the request. + * The goal is to transform RouteHandlers to ResolvedRouteHandler. + */ +export type ResolvedRouteHandler = (request: Request) => ResponseObject + +export type ParameterProvidingMiddleware = (request: Request) => Either + +export interface HandledRoute { + /** + * Set a programmatic name for this route. + * @param name + */ + alias(name: string): this +} + +export class Route { + protected preflight: Collection = new Collection() + + protected parameters: Collection> = new Collection>() + + protected postflight: Collection = new Collection() + + protected aliases: Collection = new Collection() + + protected handler?: Constructable<(...x: THandlerParams) => TReturn> + + constructor( + protected method: HTTPMethod | HTTPMethod[], + protected route: string, + ) {} + + /** + * Set a programmatic name for this route. + * @param name + */ + public alias(name: string): this { + this.aliases.push(name) + return this + } + + /** + * Get the string-form of the route. + */ + public getRoute(): string { + return this.route + } + + /** + * Get the string-form methods supported by the route. + */ + public getMethods(): HTTPMethod[] { + if ( !Array.isArray(this.method) ) { + return [this.method] + } + + return this.method + } + + /** + * Get preflight middleware for this route. + */ + public getPreflight(): Collection { + return this.preflight.clone() + } + + /** + * Get postflight middleware for this route. + */ + public getPostflight(): Collection { + return this.postflight.clone() + } + + /** + * Returns true if this route matches the given HTTP verb and request path. + * @param method + * @param potential + */ + public match(method: HTTPMethod, potential: string): boolean { + if ( Array.isArray(this.method) && !this.method.includes(method) ) { + return false + } else if ( !Array.isArray(this.method) && this.method !== method ) { + return false + } + + return Boolean(this.extract(potential)) + } + + /** + * Given a request path, try to extract this route's paramters from the path string. + * + * @example + * For route `/foo/:bar/baz` and input `/foo/bob/baz`, extracts: + * + * ```typescript + * { + * bar: 'bob' + * } + * ``` + * + * @param potential + */ + public extract(potential: string): {[key: string]: string} | undefined { + const routeParts = (this.route.startsWith('/') ? this.route.substr(1) : this.route).split('/') + const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/') + + const params: any = {} + let wildcardIdx = 0 + + for ( let i = 0; i < routeParts.length; i += 1 ) { + const part = routeParts[i] + + if ( part === '**' ) { + params[wildcardIdx] = potentialParts.slice(i).join('/') + return params + } + + if ( (potentialParts.length - 1) < i ) { + return + } + + if ( part === '*' ) { + params[wildcardIdx] = potentialParts[i] + wildcardIdx += 1 + } else if ( part.startsWith(':') ) { + params[part.substr(1)] = potentialParts[i] + } else if ( potentialParts[i] !== part ) { + return + } + } + + // If we got here, we didn't find a ** + // So, if the lengths are different, fail + if ( routeParts.length !== potentialParts.length ) { + return + } + return params + } + + public parameterMiddleware( + handler: ParameterProvidingMiddleware, + ): Route> { + const route = new Route>( + this.method, + this.route, + ) + + route.copyFrom(this) + route.parameters.push(handler) + return route + } + + private copyFrom(other: Route) { + this.preflight = other.preflight.clone() + this.postflight = other.postflight.clone() + this.aliases = other.aliases.clone() + } + + public calls( + key: TypedDependencyKey, + selector: (x: TKey) => (...params: THandlerParams) => TReturn, + ): HandledRoute { + this.handler = constructable(key) + .tap(inst => Function.prototype.bind.call(inst as any, selector(inst)) as ((...params: THandlerParams) => TReturn)) + + return this + } + + public pre(middleware: Instantiable): this { + this.preflight.push(request => request.make(middleware).apply()) + return this + } + + public post(middleware: Instantiable): this { + this.postflight.push(request => request.make(middleware).apply()) + return this + } + + // validator + + /** Cast the route to an intelligible string. */ + toString(): string { + const method = Array.isArray(this.method) ? this.method : [this.method] + return `${method.join('|')} -> ${this.route}` + } + + /** Prefix the route's path with the given prefix, normalizing `/` characters. */ + private prepend(prefix: string): this { + if ( !prefix.endsWith('/') ) { + prefix = `${prefix}/` + } + if ( this.route.startsWith('/') ) { + this.route = this.route.substring(1) + } + this.route = `${prefix}${this.route}` + return this + } +} diff --git a/src/orm/model/Model.ts b/src/orm/model/Model.ts index b828a7c..3f7f0ac 100644 --- a/src/orm/model/Model.ts +++ b/src/orm/model/Model.ts @@ -3,7 +3,7 @@ import {Container, Inject, Instantiable, isInstantiable, StaticClass} from '../. import {DatabaseService} from '../DatabaseService' import {ModelBuilder} from './ModelBuilder' import {getFieldsMeta, ModelField} from './Field' -import {deepCopy, Pipe, Collection, Awaitable, uuid4, isKeyof} from '../../util' +import {deepCopy, Collection, Awaitable, uuid4, isKeyof, Pipeline} from '../../util' import {EscapeValueObject} from '../dialect/SQLDialect' import {AppClass} from '../../lifecycle/AppClass' import {Logging} from '../../service/Logging' @@ -828,8 +828,8 @@ export abstract class Model> extends AppClass implements Bus /** * Creates a new Pipe instance containing this model instance. */ - public pipe(): Pipe { - return Pipe.wrap(this) + public pipe(pipeline: Pipeline): TOut { + return pipeline.apply(this) } /** @@ -867,12 +867,11 @@ export abstract class Model> extends AppClass implements Bus this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`) - return getFieldsMeta(this) - .pipe() + return Pipeline.id>() .unless(ctor.populateKeyOnInsert, fields => { return fields.where('databaseKey', '!=', this.keyName()) }) - .get() + .apply(getFieldsMeta(this)) .keyMap('databaseKey', inst => (this as any)[inst.modelKey]) } diff --git a/src/orm/schema/PostgresSchema.ts b/src/orm/schema/PostgresSchema.ts index c8d3a95..44d2b8a 100644 --- a/src/orm/schema/PostgresSchema.ts +++ b/src/orm/schema/PostgresSchema.ts @@ -66,14 +66,14 @@ export class PostgresSchema extends Schema { cols.each(col => { table.column(col.column_name) .type(col.data_type) - .pipe() - .when(col.is_nullable, builder => { - builder.isNullable() - return builder - }) - .when(col.column_default, builder => { - builder.default(raw(col.column_default)) - return builder + .pipe(line => { + return line + .when(col.is_nullable, builder => { + return builder.nullable() + }) + .when(col.column_default, builder => { + return builder.default(raw(col.column_default)) + }) }) }) @@ -92,26 +92,25 @@ export class PostgresSchema extends Schema { table.constraint(key) .type(ConstraintType.Unique) - .pipe() - .peek(constraint => { + .tap(constraint => { collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase .pluck('column_name') .each(column => constraint.field(column)) }) - .get() .flagAsExistingInSchema() } // Apply the primary key constraints constraints.where('constraint_type', '=', 'p') - .pipe() - .when(c => c.count() > 0, pk => { - pk.each(constraint => { - table.column(constraint.column_name) - .primary() - }) + .pipe(line => { + return line.when(c => c.count() > 0, pk => { + pk.each(constraint => { + table.column(constraint.column_name) + .primary() + }) - return pk + return pk + }) }) // Apply the non-null constraints @@ -158,15 +157,16 @@ export class PostgresSchema extends Schema { } table.index(key) - .pipe() - .peek(idx => { - collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase - .pluck('column_name') - .each(col => idx.field(col)) + .pipe(builder => { + return builder + .peek(idx => { + collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase + .pluck('column_name') + .each(col => idx.field(col)) + }) + .when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary()) + .when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique()) }) - .when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary()) - .when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique()) - .get() .flagAsExistingInSchema() } diff --git a/src/orm/schema/TableBuilder.ts b/src/orm/schema/TableBuilder.ts index 52342ef..b4f4943 100644 --- a/src/orm/schema/TableBuilder.ts +++ b/src/orm/schema/TableBuilder.ts @@ -1,4 +1,4 @@ -import {collect, Maybe, ParameterizedCallback, Pipe} from '../../util' +import {collect, Maybe, ParameterizedCallback, Pipeline} from '../../util' import {FieldType} from '../types' import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect' @@ -33,7 +33,7 @@ export abstract class SchemaBuilderBase { protected existsInSchema = false /** If the resource exists in the schema, the unaltered values it has. */ - public originalFromSchema?: SchemaBuilderBase + public originalFromSchema?: this constructor( /** The name of the schema item. */ @@ -44,7 +44,7 @@ export abstract class SchemaBuilderBase { * Clone the properties of this resource to a different instance. * @param newBuilder */ - public cloneTo(newBuilder: SchemaBuilderBase): SchemaBuilderBase { + public cloneTo(newBuilder: this): this { newBuilder.shouldDrop = this.shouldDrop newBuilder.shouldRenameTo = this.shouldRenameTo newBuilder.shouldSkipIfExists = this.shouldSkipIfExists @@ -129,9 +129,14 @@ export abstract class SchemaBuilderBase { return this } - /** Get a Pipe containing this instance. */ - pipe(): Pipe { - return Pipe.wrap(this) + /** Build and apply a pipeline. */ + pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { + return builder(Pipeline.id()).apply(this) + } + + tap(op: (x: this) => unknown): this { + op(this) + return this } /** @@ -163,8 +168,6 @@ export class ColumnBuilder extends SchemaBuilderBase { /** True if this column should contain distinct values. */ protected shouldBeUnique = false - public originalFromSchema?: ColumnBuilder - constructor( name: string, @@ -174,7 +177,7 @@ export class ColumnBuilder extends SchemaBuilderBase { super(name) } - public cloneTo(newBuilder: ColumnBuilder): ColumnBuilder { + public cloneTo(newBuilder: this): this { super.cloneTo(newBuilder) newBuilder.targetType = this.targetType newBuilder.shouldBeNullable = this.shouldBeNullable @@ -324,7 +327,6 @@ export enum ConstraintType { * Builder to specify the schema of a table constraint. */ export class ConstraintBuilder extends SchemaBuilderBase { - public originalFromSchema?: ConstraintBuilder /** The fields included in this constraint. */ protected fields: Set = new Set() @@ -359,7 +361,7 @@ export class ConstraintBuilder extends SchemaBuilderBase { return this.constraintExpression } - public cloneTo(newBuilder: ConstraintBuilder): ConstraintBuilder { + public cloneTo(newBuilder: this): this { super.cloneTo(newBuilder) newBuilder.fields = new Set([...this.fields]) newBuilder.constraintType = this.constraintType @@ -436,8 +438,6 @@ export class IndexBuilder extends SchemaBuilderBase { /** True if this is a primary key index. */ protected shouldBePrimary = false - public originalFromSchema?: IndexBuilder - constructor( name: string, @@ -447,7 +447,7 @@ export class IndexBuilder extends SchemaBuilderBase { super(name) } - public cloneTo(newBuilder: IndexBuilder): IndexBuilder { + public cloneTo(newBuilder: this): this { super.cloneTo(newBuilder) newBuilder.fields = new Set([...this.fields]) newBuilder.removedFields = new Set([...this.removedFields]) @@ -555,9 +555,7 @@ export class TableBuilder extends SchemaBuilderBase { */ protected constraints: {[key: string]: ConstraintBuilder} = {} - public originalFromSchema?: TableBuilder - - public cloneTo(newBuilder: TableBuilder): TableBuilder { + public cloneTo(newBuilder: this): this { super.cloneTo(newBuilder) newBuilder.columns = {...this.columns} newBuilder.indexes = {...this.indexes} @@ -743,8 +741,7 @@ export class TableBuilder extends SchemaBuilderBase { const name = this.getNextAvailableConstraintName(ConstraintType.Unique) this.constraint(name) .type(ConstraintType.Unique) - .pipe() - .peek(constraint => { + .tap(constraint => { fields.forEach(field => constraint.field(field)) }) diff --git a/src/service/Routing.ts b/src/service/Routing.ts index 651efef..bd56ce7 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -1,5 +1,5 @@ import {Inject, Singleton} from '../di' -import {Awaitable, Collection, ErrorWithContext, Maybe, Pipe, universalPath, UniversalPath} from '../util' +import {Awaitable, Collection, ErrorWithContext, Maybe, Pipeline, universalPath, UniversalPath} from '../util' import {Unit, UnitStatus} from '../lifecycle/Unit' import {Logging} from './Logging' import {Route} from '../http/routing/Route' @@ -168,7 +168,7 @@ export class Routing extends Unit { const isSSL = rawHost.startsWith('https://') const port = this.config.get('server.port', 8000) - return Pipe.wrap(rawHost) + return Pipeline.id() .unless( host => host.startsWith('http://') || host.startsWith('https'), host => `http://${host}`, @@ -187,7 +187,7 @@ export class Routing extends Unit { }, ) .tap(host => universalPath(host)) - .get() + .apply(rawHost) } /** diff --git a/src/util/collection/AsyncCollection.ts b/src/util/collection/AsyncCollection.ts index aa29c85..ca85531 100644 --- a/src/util/collection/AsyncCollection.ts +++ b/src/util/collection/AsyncCollection.ts @@ -7,7 +7,7 @@ import { } from './Collection' import {Iterable, StopIteration} from './Iterable' import {applyWhere, WhereOperator} from './where' -import {AsyncPipe, Pipe} from '../support/Pipe' +import {AsyncPipe, Pipeline} from '../support/Pipe' type AsyncCollectionComparable = CollectionItem[] | Collection | AsyncCollection type AsyncKeyFunction = (item: CollectionItem, index: number) => CollectionItem | Promise> type AsyncCollectionFunction = (items: AsyncCollection) => T2 @@ -799,18 +799,15 @@ export class AsyncCollection { } /** - * Return the value of the function, passing this collection to it. - * @param {AsyncCollectionFunction} func + * Return a new Pipe of this collection. */ - pipeTo(func: AsyncCollectionFunction): any { - return func(this) + pipeTo(pipeline: Pipeline): TOut { + return pipeline.apply(this) } - /** - * Return a new Pipe of this collection. - */ - pipe(): Pipe> { - return Pipe.wrap(this) + /** Build and apply a pipeline. */ + pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { + return builder(Pipeline.id()).apply(this) } /** diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index ce18547..b9d44a8 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -1,4 +1,4 @@ -import {AsyncPipe, Pipe} from '../support/Pipe' +import {AsyncPipe, Pipeline} from '../support/Pipe' type CollectionItem = T type MaybeCollectionItem = CollectionItem | undefined @@ -822,8 +822,13 @@ class Collection { /** * Return a new Pipe of this collection. */ - pipe(): Pipe> { - return Pipe.wrap(this) + pipeTo(pipeline: Pipeline): TOut { + return pipeline.apply(this) + } + + /** Build and apply a pipeline. */ + pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { + return builder(Pipeline.id()).apply(this) } /** diff --git a/src/util/support/Messages.ts b/src/util/support/Messages.ts index 2858a86..6cdbf44 100644 --- a/src/util/support/Messages.ts +++ b/src/util/support/Messages.ts @@ -1,6 +1,6 @@ import {collect} from '../collection/Collection' import {InvalidJSONStateError, JSONState, Rehydratable} from './Rehydratable' -import {Pipe} from './Pipe' +import {Pipeline} from './Pipe' /** * A class for building and working with messages grouped by keys. @@ -131,9 +131,14 @@ export class Messages implements Rehydratable { } /** - * Get a new Pipe object wrapping this instance. + * Return a new Pipe of this collection. */ - pipe(): Pipe { - return Pipe.wrap(this) + pipeTo(pipeline: Pipeline): TOut { + return pipeline.apply(this) + } + + /** Build and apply a pipeline. */ + pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { + return builder(Pipeline.id()).apply(this) } } diff --git a/src/util/support/Pipe.ts b/src/util/support/Pipe.ts index faf9779..ac8402f 100644 --- a/src/util/support/Pipe.ts +++ b/src/util/support/Pipe.ts @@ -1,14 +1,14 @@ /** * A closure that maps a given pipe item to a different type. */ -import {Awaitable} from './types' +import {Awaitable, Maybe} from './types' export type PipeOperator = (subject: T) => T2 /** * A closure that maps a given pipe item to an item of the same type. */ -export type ReflexivePipeOperator = (subject: T) => T +export type ReflexivePipeOperator = (subject: T) => Maybe /** * A condition or condition-resolving function for pipe methods. @@ -19,48 +19,14 @@ export type PipeCondition = boolean | ((subject: T) => boolean) * A class for writing chained/conditional operations in a data-flow manner. * * This is useful when you need to do a series of operations on an object, perhaps conditionally. - * - * @example - * Say we have a Collection of items, and want to apply some transformations and filtering based on arguments: - * - * ```typescript - * const collection = collect([1, 2, 3, 4, 5, 6, 7, 8, 9]) - * - * function transform(collection, evensOnly = false, returnEntireCollection = false) { - * return Pipe.wrap(collection) - * .when(evensOnly, coll => { - * return coll.filter(x => !(x % 2)) - * }) - * .unless(returnEntireCollection, coll => { - * return coll.take(3) - * }) - * .tap(coll => { - * return coll.map(x => x * 2)) - * }) - * .get() - * } - * - * transform(collection) // => Collection[2, 4, 6] - * - * transform(collection, true) // => Collection[4, 8, 12] - * - * transform(collection, false, true) // => Collection[2, 4, 6, 8, 10, 12, 14, 16, 18] - * ``` */ -export class Pipe { - /** - * Return a new Pipe containing the given subject. - * @param subject - */ - static wrap(subject: subjectType): Pipe { - return new Pipe(subject) +export class Pipeline { + static id(): Pipeline { + return new Pipeline(x => x) } constructor( - /** - * The item being operated on. - */ - private subject: T, + protected readonly factory: (TIn: TIn) => TOut, ) {} /** @@ -75,17 +41,22 @@ export class Pipe { * * @param op */ - tap(op: PipeOperator): Pipe { - return new Pipe(op(this.subject)) + tap(op: PipeOperator): Pipeline { + return new Pipeline((val: TIn) => { + return op(this.factory(val)) + }) } /** - * Like tap, but always returns the original pipe. + * Like tap, but always returns the original pipe type. * @param op */ - peek(op: PipeOperator): this { - op(this.subject) - return this + peek(op: PipeOperator): Pipeline { + return new Pipeline((val: TIn) => { + const nextVal = this.factory(val) + op(nextVal) + return nextVal + }) } /** @@ -95,14 +66,20 @@ export class Pipe { * @param check * @param op */ - when(check: PipeCondition, op: ReflexivePipeOperator): Pipe { - if ( - (typeof check === 'function' && check(this.subject)) - || (typeof check !== 'function' && check) ) { - return Pipe.wrap(op(this.subject)) - } + when(check: PipeCondition, op: ReflexivePipeOperator): Pipeline { + return new Pipeline((val: TIn) => { + const nextVal = this.factory(val) + if ( this.checkCondition(check, nextVal) ) { + const appliedVal = op(nextVal) + if ( typeof appliedVal === 'undefined' ) { + return nextVal + } + + return appliedVal + } - return this + return nextVal + }) } /** @@ -112,42 +89,32 @@ export class Pipe { * @param check * @param op */ - unless(check: PipeCondition, op: ReflexivePipeOperator): Pipe { - if ( - (typeof check === 'function' && check(this.subject)) - || (typeof check !== 'function' && check) ) { - return this - } + unless(check: PipeCondition, op: ReflexivePipeOperator): Pipeline { + return new Pipeline((val: TIn) => { + const nextVal = this.factory(val) + if ( !this.checkCondition(check, nextVal) ) { + const appliedVal = op(nextVal) + if ( typeof appliedVal === 'undefined' ) { + return nextVal + } - return Pipe.wrap(op(this.subject)) - } + return appliedVal + } - /** - * Alias of `unless()`. - * @param check - * @param op - */ - whenNot(check: PipeCondition, op: ReflexivePipeOperator): Pipe { - return this.unless(check, op) + return nextVal + }) } /** - * Get the item in the pipe. - * - * @example - * ```typescript - * Pipe.wrap(4).get() // => 4 - * ``` + * Apply the pipeline to an input. */ - get(): T { - return this.subject + apply(input: TIn): TOut { + return this.factory(input) } - /** - * Get an AsyncPipe with the current item in the pipe. - */ - async(): AsyncPipe { - return AsyncPipe.wrap(this.subject) + protected checkCondition(check: PipeCondition, val: TOut): boolean { + return (typeof check === 'function' && check(val)) + || (typeof check !== 'function' && check) } } @@ -280,13 +247,6 @@ export class AsyncPipe { return this.subject() } - /** - * Resolve the value and return it in a sync `Pipe` instance. - */ - async sync(): Promise> { - return Pipe.wrap(await this.subject()) - } - /** Get the transformed value from the pipe. Allows awaiting the pipe directly. */ then(): Promise { return this.resolve() diff --git a/src/util/support/path.ts b/src/util/support/path.ts index 6ae6afe..e629ab4 100644 --- a/src/util/support/path.ts +++ b/src/util/support/path.ts @@ -5,7 +5,7 @@ import * as mime from 'mime-types' import {FileNotFoundError, Filesystem} from './path/Filesystem' import {Collection} from '../collection/Collection' import {Readable, Writable} from 'stream' -import {Pipe} from './Pipe' +import {Pipeline} from './Pipe' /** * An item that could represent a path. @@ -533,8 +533,15 @@ export class UniversalPath { return false } - /** Get a new Pipe instance wrapping this. */ - toPipe(): Pipe { - return Pipe.wrap(this) + /** + * Return a new Pipe of this collection. + */ + pipeTo(pipeline: Pipeline): TOut { + return pipeline.apply(this) + } + + /** Build and apply a pipeline. */ + pipe(builder: (pipeline: Pipeline) => Pipeline): TOut { + return builder(Pipeline.id()).apply(this) } } diff --git a/src/util/support/types.ts b/src/util/support/types.ts index 4393da0..6d9a686 100644 --- a/src/util/support/types.ts +++ b/src/util/support/types.ts @@ -4,6 +4,28 @@ export type Awaitable = T | Promise /** Type alias for something that may be undefined. */ export type Maybe = T | undefined +export type Either = Left | Right + +export type Left = [T, undefined] + +export type Right = [undefined, T] + +export function isLeft(what: Either): what is Left { + return typeof what[1] === 'undefined' +} + +export function isRight(what: Either): what is Right { + return typeof what[0] === 'undefined' +} + +export function left(what: T): Left { + return [what, undefined] +} + +export function right(what: T): Right { + return [undefined, what] +} + /** Type alias for a callback that accepts a typed argument. */ export type ParameterizedCallback = ((arg: T) => any) @@ -30,3 +52,7 @@ export function hasOwnProperty(obj: X, prop export interface TypeTag { readonly __typeTag: S } + +export type PrefixTypeArray = [T, ...TArr] +export type SuffixTypeArray = [...TArr, T] +export type TypeArraySignature = (...params: TArr) => TReturn diff --git a/src/validation/Validator.ts b/src/validation/Validator.ts index 43b4716..5080ba4 100644 --- a/src/validation/Validator.ts +++ b/src/validation/Validator.ts @@ -1,17 +1,92 @@ import { z } from 'zod' import {InjectionAware} from '../di' +import {ErrorWithContext, TypeTag} from '../util' import {ZodifyRecipient} from './ZodifyRecipient' import {ZodifyRegistrar} from './ZodifyRegistrar' +import {Logging} from '../service/Logging' +/** Type tag for a validated runtime type. */ +export type Valid = TypeTag<'@extollo/lib:Valid'> & T + +/** + * Error thrown if the schema for a validator cannot be located. + */ export class InvalidSchemaMappingError extends Error { constructor(message = 'Unable to resolve schema for validator.') { super(message) } } +/** + * Interface defining validation error messages. + */ +export interface ValidationFormErrors { + formErrors: string[], + fieldErrors: { + [k in keyof T]: string[] + } +} + +/** + * Error thrown when data validation has failed. + */ +export class ValidationError extends ErrorWithContext { + constructor( + public readonly formErrors: ValidationFormErrors, + message = 'Invalid form data', + ) { + super(message) + } +} + +/** + * Validates input data against a schema at runtime. + */ export class Validator extends InjectionAware implements ZodifyRecipient { __exZodifiedSchemata: number[] = [] + /** + * Parse the input data against the schema. + * @throws ValidationError + * @param from + */ + public parse(from: unknown): Valid { + try { + return this.getZod().parse(from) as Valid + } catch (e: unknown) { + if ( e instanceof z.ZodError ) { + throw new ValidationError(e.formErrors as ValidationFormErrors) + } + + throw e + } + } + + /** + * Typeguard for the input schema. + * @param what + */ + public is(what: unknown): what is Valid { + try { + this.parse(what) + return true + } catch (e: unknown) { + this.log().verbose(`Error during validation: ${e}`) + + if ( e instanceof ValidationError ) { + return false + } + + throw e + } + } + + /** Get the logging service. */ + protected log(): Logging { + return this.make(Logging) + } + + /** Get the Zod schema. */ protected getZod(): z.ZodType { // eslint-disable-next-line no-underscore-dangle if ( this.__exZodifiedSchemata.length < 1 ) {