Start routing and pipeline rewrite
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2022-01-17 15:57:40 -06:00
parent 9b8333295f
commit 8cf19792a6
34 changed files with 470 additions and 1654 deletions

View File

@ -14,8 +14,4 @@ export * from './event/UserFlushedEvent'
export * from './repository/orm/ORMUser' export * from './repository/orm/ORMUser'
export * from './repository/orm/ORMUserRepository' export * from './repository/orm/ORMUserRepository'
export * from './ui/basic/BasicRegisterFormRequest'
export * from './ui/basic/BasicLoginFormRequest'
export * from './ui/basic/BasicLoginController'
export * from './config' export * from './config'

View File

@ -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 <BasicLoginController> 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<ResponseObject> {
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
try {
const data: Valid<BasicLoginFormData> = 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<ResponseObject> {
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
try {
const data: Valid<BasicRegisterFormData> = 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<ResponseObject> {
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,
})
}
}

View File

@ -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<BasicLoginFormData> {
protected getRules(): ValidationRules {
return {
username: [
Is.required,
Str.lengthMin(1),
],
password: [
Is.required,
Str.lengthMin(1),
],
}
}
}

View File

@ -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<BasicRegisterFormData> {
protected getRules(): ValidationRules {
return {
username: [
Is.required,
Str.lengthMin(1),
],
password: [
Is.required,
Str.lengthMin(1),
Str.confirmed,
],
}
}
}

11
src/di/constructable.ts Normal file
View File

@ -0,0 +1,11 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
import {Pipeline} from '../util'
export type Constructable<T> = Pipeline<Container, T>
export function constructable<T>(key: TypedDependencyKey<T>): Constructable<T> {
return new Pipeline<Container, T>(
container => container.make(key),
)
}

View File

@ -14,3 +14,4 @@ export * from './types'
export * from './decorator/injection' export * from './decorator/injection'
export * from './InjectionAware' export * from './InjectionAware'
export * from './constructable'

View File

View File

@ -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 = <MyFormRequest> request.make(MyFormRequest)
*
* // Instantiate with some container:
* const data = new MyFormRequest(someDataContainer)
* ```
*/
@Injectable()
export abstract class FormRequest<T> extends AppClass {
/** The cached validation result. */
protected cachedResult?: Valid<T>
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<ValidationRules>
/**
* 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<Valid<T>> {
if ( !this.cachedResult ) {
const validator = <Validator<T>> this.make(Validator, await this.getRules())
this.cachedResult = await validator.validate(this.data.input())
}
return this.cachedResult
}
}

View File

@ -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<T> extends ErrorWithContext {
constructor(
/** The original input data. */
public readonly data: unknown,
/** The validator instance used. */
public readonly validator: Validator<T>,
/** 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<T> {
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<T>.
* If it is invalid, a ValidationError is thrown.
* @param data
*/
public async validate(data: unknown): Promise<Valid<T>> {
const messages = await this.validateAndGetErrors(data)
if ( messages.any() ) {
throw new ValidationError<T>(data, this, messages)
}
return data as Valid<T>
}
/**
* Returns true if the given data is valid and type aliases it as Valid<T>.
* @param data
*/
public async isValid(data: unknown): Promise<boolean> {
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<Messages> {
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<any>(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
}
}

View File

@ -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'

View File

@ -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<T>(formRequestClass: Instantiable<FormRequest<T>>): RouteHandler {
return async function formRequestRouteHandler(request: Request): Promise<ResponseObject> {
const formRequestInstance = <FormRequest<T>> request.make(formRequestClass)
try {
await formRequestInstance.get()
request.registerSingletonInstance<FormRequest<T>>(formRequestClass, formRequestInstance)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return e.errors.toJSON()
}
throw e
}
}
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }
}
}
*/

View File

@ -1,5 +0,0 @@
export { Arr } from './arrays'
export { Cast } from './inference'
export { Num } from './numeric'
export { Is } from './presence'
export { Str } from './strings'

View File

@ -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,
}

View File

@ -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<ValidationResult>
/** 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<ValidationResult>
/** 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> = T

View File

@ -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 }

View File

@ -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<void> {
this.cli.registerTemplate(templateForm)
}
}

View File

@ -11,6 +11,7 @@ import {Controller} from '../Controller'
import {Middlewares} from '../../service/Middlewares' import {Middlewares} from '../../service/Middlewares'
import {Middleware} from './Middleware' import {Middleware} from './Middleware'
import {Config} from '../../service/Config' import {Config} from '../../service/Config'
import {Validator} from '../../validation/Validator'
/** /**
* Type alias for an item that is a valid response object, or lack thereof. * 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. * for nested groups and resolving handlers.
* *
* This function attempts to resolve the route handlers ahead of time to cache * 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. */ /** Pre-compiled route handler for the main route handler for this route. */
protected compiledPostflight?: ResolvedRouteHandler[] protected compiledPostflight?: ResolvedRouteHandler[]
protected validator?: Validator<unknown>
/** Programmatic aliases of this route. */ /** Programmatic aliases of this route. */
public aliases: string[] = [] public aliases: string[] = []
@ -387,6 +390,15 @@ export class Route extends AppClass {
return this return this
} }
input(validator: Validator<any>): this {
if ( !this.validator ) {
//
}
this.validator = validator
return this
}
/** Prefix the route's path with the given prefix, normalizing `/` characters. */ /** Prefix the route's path with the given prefix, normalizing `/` characters. */
private prepend(prefix: string): this { private prepend(prefix: string): this {
if ( !prefix.endsWith('/') ) { if ( !prefix.endsWith('/') ) {

209
src/http/routing/Route2.ts Normal file
View File

@ -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<ResponseObject>
/**
* 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<T> = (request: Request) => Either<ResponseObject, T>
export interface HandledRoute<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
/**
* Set a programmatic name for this route.
* @param name
*/
alias(name: string): this
}
export class Route<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
protected preflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
protected parameters: Collection<ParameterProvidingMiddleware<unknown>> = new Collection<ParameterProvidingMiddleware<unknown>>()
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
protected aliases: Collection<string> = new Collection<string>()
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<ResolvedRouteHandler> {
return this.preflight.clone()
}
/**
* Get postflight middleware for this route.
*/
public getPostflight(): Collection<ResolvedRouteHandler> {
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<T>(
handler: ParameterProvidingMiddleware<T>,
): Route<TReturn, PrefixTypeArray<T, THandlerParams>> {
const route = new Route<TReturn, PrefixTypeArray<T, THandlerParams>>(
this.method,
this.route,
)
route.copyFrom(this)
route.parameters.push(handler)
return route
}
private copyFrom(other: Route<TReturn, any>) {
this.preflight = other.preflight.clone()
this.postflight = other.postflight.clone()
this.aliases = other.aliases.clone()
}
public calls<TKey>(
key: TypedDependencyKey<TKey>,
selector: (x: TKey) => (...params: THandlerParams) => TReturn,
): HandledRoute<TReturn, THandlerParams> {
this.handler = constructable<TKey>(key)
.tap(inst => Function.prototype.bind.call(inst as any, selector(inst)) as ((...params: THandlerParams) => TReturn))
return this
}
public pre(middleware: Instantiable<Middleware>): this {
this.preflight.push(request => request.make<Middleware>(middleware).apply())
return this
}
public post(middleware: Instantiable<Middleware>): this {
this.postflight.push(request => request.make<Middleware>(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
}
}

View File

@ -3,7 +3,7 @@ import {Container, Inject, Instantiable, isInstantiable, StaticClass} from '../.
import {DatabaseService} from '../DatabaseService' import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder' import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field' 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 {EscapeValueObject} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass' import {AppClass} from '../../lifecycle/AppClass'
import {Logging} from '../../service/Logging' import {Logging} from '../../service/Logging'
@ -828,8 +828,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
/** /**
* Creates a new Pipe instance containing this model instance. * Creates a new Pipe instance containing this model instance.
*/ */
public pipe(): Pipe<this> { public pipe<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return Pipe.wrap(this) return pipeline.apply(this)
} }
/** /**
@ -867,12 +867,11 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`) this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
return getFieldsMeta(this) return Pipeline.id<Collection<ModelField>>()
.pipe()
.unless(ctor.populateKeyOnInsert, fields => { .unless(ctor.populateKeyOnInsert, fields => {
return fields.where('databaseKey', '!=', this.keyName()) return fields.where('databaseKey', '!=', this.keyName())
}) })
.get() .apply(getFieldsMeta(this))
.keyMap('databaseKey', inst => (this as any)[inst.modelKey]) .keyMap('databaseKey', inst => (this as any)[inst.modelKey])
} }

View File

@ -66,14 +66,14 @@ export class PostgresSchema extends Schema {
cols.each(col => { cols.each(col => {
table.column(col.column_name) table.column(col.column_name)
.type(col.data_type) .type(col.data_type)
.pipe() .pipe(line => {
.when(col.is_nullable, builder => { return line
builder.isNullable() .when(col.is_nullable, builder => {
return builder return builder.nullable()
}) })
.when(col.column_default, builder => { .when(col.column_default, builder => {
builder.default(raw(col.column_default)) return builder.default(raw(col.column_default))
return builder })
}) })
}) })
@ -92,26 +92,25 @@ export class PostgresSchema extends Schema {
table.constraint(key) table.constraint(key)
.type(ConstraintType.Unique) .type(ConstraintType.Unique)
.pipe() .tap(constraint => {
.peek(constraint => {
collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase
.pluck<string>('column_name') .pluck<string>('column_name')
.each(column => constraint.field(column)) .each(column => constraint.field(column))
}) })
.get()
.flagAsExistingInSchema() .flagAsExistingInSchema()
} }
// Apply the primary key constraints // Apply the primary key constraints
constraints.where('constraint_type', '=', 'p') constraints.where('constraint_type', '=', 'p')
.pipe() .pipe(line => {
.when(c => c.count() > 0, pk => { return line.when(c => c.count() > 0, pk => {
pk.each(constraint => { pk.each(constraint => {
table.column(constraint.column_name) table.column(constraint.column_name)
.primary() .primary()
}) })
return pk return pk
})
}) })
// Apply the non-null constraints // Apply the non-null constraints
@ -158,15 +157,16 @@ export class PostgresSchema extends Schema {
} }
table.index(key) table.index(key)
.pipe() .pipe(builder => {
.peek(idx => { return builder
collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase .peek(idx => {
.pluck<string>('column_name') collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase
.each(col => idx.field(col)) .pluck<string>('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() .flagAsExistingInSchema()
} }

View File

@ -1,4 +1,4 @@
import {collect, Maybe, ParameterizedCallback, Pipe} from '../../util' import {collect, Maybe, ParameterizedCallback, Pipeline} from '../../util'
import {FieldType} from '../types' import {FieldType} from '../types'
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect' import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
@ -33,7 +33,7 @@ export abstract class SchemaBuilderBase {
protected existsInSchema = false protected existsInSchema = false
/** If the resource exists in the schema, the unaltered values it has. */ /** If the resource exists in the schema, the unaltered values it has. */
public originalFromSchema?: SchemaBuilderBase public originalFromSchema?: this
constructor( constructor(
/** The name of the schema item. */ /** The name of the schema item. */
@ -44,7 +44,7 @@ export abstract class SchemaBuilderBase {
* Clone the properties of this resource to a different instance. * Clone the properties of this resource to a different instance.
* @param newBuilder * @param newBuilder
*/ */
public cloneTo(newBuilder: SchemaBuilderBase): SchemaBuilderBase { public cloneTo(newBuilder: this): this {
newBuilder.shouldDrop = this.shouldDrop newBuilder.shouldDrop = this.shouldDrop
newBuilder.shouldRenameTo = this.shouldRenameTo newBuilder.shouldRenameTo = this.shouldRenameTo
newBuilder.shouldSkipIfExists = this.shouldSkipIfExists newBuilder.shouldSkipIfExists = this.shouldSkipIfExists
@ -129,9 +129,14 @@ export abstract class SchemaBuilderBase {
return this return this
} }
/** Get a Pipe containing this instance. */ /** Build and apply a pipeline. */
pipe(): Pipe<this> { pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return Pipe.wrap<this>(this) 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. */ /** True if this column should contain distinct values. */
protected shouldBeUnique = false protected shouldBeUnique = false
public originalFromSchema?: ColumnBuilder
constructor( constructor(
name: string, name: string,
@ -174,7 +177,7 @@ export class ColumnBuilder extends SchemaBuilderBase {
super(name) super(name)
} }
public cloneTo(newBuilder: ColumnBuilder): ColumnBuilder { public cloneTo(newBuilder: this): this {
super.cloneTo(newBuilder) super.cloneTo(newBuilder)
newBuilder.targetType = this.targetType newBuilder.targetType = this.targetType
newBuilder.shouldBeNullable = this.shouldBeNullable newBuilder.shouldBeNullable = this.shouldBeNullable
@ -324,7 +327,6 @@ export enum ConstraintType {
* Builder to specify the schema of a table constraint. * Builder to specify the schema of a table constraint.
*/ */
export class ConstraintBuilder extends SchemaBuilderBase { export class ConstraintBuilder extends SchemaBuilderBase {
public originalFromSchema?: ConstraintBuilder
/** The fields included in this constraint. */ /** The fields included in this constraint. */
protected fields: Set<string> = new Set<string>() protected fields: Set<string> = new Set<string>()
@ -359,7 +361,7 @@ export class ConstraintBuilder extends SchemaBuilderBase {
return this.constraintExpression return this.constraintExpression
} }
public cloneTo(newBuilder: ConstraintBuilder): ConstraintBuilder { public cloneTo(newBuilder: this): this {
super.cloneTo(newBuilder) super.cloneTo(newBuilder)
newBuilder.fields = new Set<string>([...this.fields]) newBuilder.fields = new Set<string>([...this.fields])
newBuilder.constraintType = this.constraintType newBuilder.constraintType = this.constraintType
@ -436,8 +438,6 @@ export class IndexBuilder extends SchemaBuilderBase {
/** True if this is a primary key index. */ /** True if this is a primary key index. */
protected shouldBePrimary = false protected shouldBePrimary = false
public originalFromSchema?: IndexBuilder
constructor( constructor(
name: string, name: string,
@ -447,7 +447,7 @@ export class IndexBuilder extends SchemaBuilderBase {
super(name) super(name)
} }
public cloneTo(newBuilder: IndexBuilder): IndexBuilder { public cloneTo(newBuilder: this): this {
super.cloneTo(newBuilder) super.cloneTo(newBuilder)
newBuilder.fields = new Set<string>([...this.fields]) newBuilder.fields = new Set<string>([...this.fields])
newBuilder.removedFields = new Set<string>([...this.removedFields]) newBuilder.removedFields = new Set<string>([...this.removedFields])
@ -555,9 +555,7 @@ export class TableBuilder extends SchemaBuilderBase {
*/ */
protected constraints: {[key: string]: ConstraintBuilder} = {} protected constraints: {[key: string]: ConstraintBuilder} = {}
public originalFromSchema?: TableBuilder public cloneTo(newBuilder: this): this {
public cloneTo(newBuilder: TableBuilder): TableBuilder {
super.cloneTo(newBuilder) super.cloneTo(newBuilder)
newBuilder.columns = {...this.columns} newBuilder.columns = {...this.columns}
newBuilder.indexes = {...this.indexes} newBuilder.indexes = {...this.indexes}
@ -743,8 +741,7 @@ export class TableBuilder extends SchemaBuilderBase {
const name = this.getNextAvailableConstraintName(ConstraintType.Unique) const name = this.getNextAvailableConstraintName(ConstraintType.Unique)
this.constraint(name) this.constraint(name)
.type(ConstraintType.Unique) .type(ConstraintType.Unique)
.pipe() .tap(constraint => {
.peek(constraint => {
fields.forEach(field => constraint.field(field)) fields.forEach(field => constraint.field(field))
}) })

View File

@ -1,5 +1,5 @@
import {Inject, Singleton} from '../di' 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 {Unit, UnitStatus} from '../lifecycle/Unit'
import {Logging} from './Logging' import {Logging} from './Logging'
import {Route} from '../http/routing/Route' import {Route} from '../http/routing/Route'
@ -168,7 +168,7 @@ export class Routing extends Unit {
const isSSL = rawHost.startsWith('https://') const isSSL = rawHost.startsWith('https://')
const port = this.config.get('server.port', 8000) const port = this.config.get('server.port', 8000)
return Pipe.wrap<string>(rawHost) return Pipeline.id<string>()
.unless( .unless(
host => host.startsWith('http://') || host.startsWith('https'), host => host.startsWith('http://') || host.startsWith('https'),
host => `http://${host}`, host => `http://${host}`,
@ -187,7 +187,7 @@ export class Routing extends Unit {
}, },
) )
.tap<UniversalPath>(host => universalPath(host)) .tap<UniversalPath>(host => universalPath(host))
.get() .apply(rawHost)
} }
/** /**

View File

@ -7,7 +7,7 @@ import {
} from './Collection' } from './Collection'
import {Iterable, StopIteration} from './Iterable' import {Iterable, StopIteration} from './Iterable'
import {applyWhere, WhereOperator} from './where' import {applyWhere, WhereOperator} from './where'
import {AsyncPipe, Pipe} from '../support/Pipe' import {AsyncPipe, Pipeline} from '../support/Pipe'
type AsyncCollectionComparable<T> = CollectionItem<T>[] | Collection<T> | AsyncCollection<T> type AsyncCollectionComparable<T> = CollectionItem<T>[] | Collection<T> | AsyncCollection<T>
type AsyncKeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2> | Promise<CollectionItem<T2>> type AsyncKeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2> | Promise<CollectionItem<T2>>
type AsyncCollectionFunction<T, T2> = (items: AsyncCollection<T>) => T2 type AsyncCollectionFunction<T, T2> = (items: AsyncCollection<T>) => T2
@ -798,19 +798,16 @@ export class AsyncCollection<T> {
return this.storedItems.range(start, end) return this.storedItems.range(start, end)
} }
/**
* Return the value of the function, passing this collection to it.
* @param {AsyncCollectionFunction} func
*/
pipeTo<T2>(func: AsyncCollectionFunction<T, T2>): any {
return func(this)
}
/** /**
* Return a new Pipe of this collection. * Return a new Pipe of this collection.
*/ */
pipe(): Pipe<AsyncCollection<T>> { pipeTo<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return Pipe.wrap(this) return pipeline.apply(this)
}
/** Build and apply a pipeline. */
pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return builder(Pipeline.id()).apply(this)
} }
/** /**

View File

@ -1,4 +1,4 @@
import {AsyncPipe, Pipe} from '../support/Pipe' import {AsyncPipe, Pipeline} from '../support/Pipe'
type CollectionItem<T> = T type CollectionItem<T> = T
type MaybeCollectionItem<T> = CollectionItem<T> | undefined type MaybeCollectionItem<T> = CollectionItem<T> | undefined
@ -822,8 +822,13 @@ class Collection<T> {
/** /**
* Return a new Pipe of this collection. * Return a new Pipe of this collection.
*/ */
pipe(): Pipe<Collection<T>> { pipeTo<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return Pipe.wrap(this) return pipeline.apply(this)
}
/** Build and apply a pipeline. */
pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return builder(Pipeline.id()).apply(this)
} }
/** /**

View File

@ -1,6 +1,6 @@
import {collect} from '../collection/Collection' import {collect} from '../collection/Collection'
import {InvalidJSONStateError, JSONState, Rehydratable} from './Rehydratable' 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. * 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<Messages> { pipeTo<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return Pipe.wrap<Messages>(this) return pipeline.apply(this)
}
/** Build and apply a pipeline. */
pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return builder(Pipeline.id()).apply(this)
} }
} }

View File

@ -1,14 +1,14 @@
/** /**
* A closure that maps a given pipe item to a different type. * A closure that maps a given pipe item to a different type.
*/ */
import {Awaitable} from './types' import {Awaitable, Maybe} from './types'
export type PipeOperator<T, T2> = (subject: T) => T2 export type PipeOperator<T, T2> = (subject: T) => T2
/** /**
* A closure that maps a given pipe item to an item of the same type. * A closure that maps a given pipe item to an item of the same type.
*/ */
export type ReflexivePipeOperator<T> = (subject: T) => T export type ReflexivePipeOperator<T> = (subject: T) => Maybe<T>
/** /**
* A condition or condition-resolving function for pipe methods. * A condition or condition-resolving function for pipe methods.
@ -19,48 +19,14 @@ export type PipeCondition<T> = boolean | ((subject: T) => boolean)
* A class for writing chained/conditional operations in a data-flow manner. * 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. * 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<T> { export class Pipeline<TIn, TOut> {
/** static id<T>(): Pipeline<T, T> {
* Return a new Pipe containing the given subject. return new Pipeline(x => x)
* @param subject
*/
static wrap<subjectType>(subject: subjectType): Pipe<subjectType> {
return new Pipe<subjectType>(subject)
} }
constructor( constructor(
/** protected readonly factory: (TIn: TIn) => TOut,
* The item being operated on.
*/
private subject: T,
) {} ) {}
/** /**
@ -75,17 +41,22 @@ export class Pipe<T> {
* *
* @param op * @param op
*/ */
tap<T2>(op: PipeOperator<T, T2>): Pipe<T2> { tap<T2>(op: PipeOperator<TOut, T2>): Pipeline<TIn, T2> {
return new Pipe(op(this.subject)) 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 * @param op
*/ */
peek<T2>(op: PipeOperator<T, T2>): this { peek<T2>(op: PipeOperator<TOut, T2>): Pipeline<TIn, TOut> {
op(this.subject) return new Pipeline((val: TIn) => {
return this const nextVal = this.factory(val)
op(nextVal)
return nextVal
})
} }
/** /**
@ -95,14 +66,20 @@ export class Pipe<T> {
* @param check * @param check
* @param op * @param op
*/ */
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> { when(check: PipeCondition<TOut>, op: ReflexivePipeOperator<TOut>): Pipeline<TIn, TOut> {
if ( return new Pipeline((val: TIn) => {
(typeof check === 'function' && check(this.subject)) const nextVal = this.factory(val)
|| (typeof check !== 'function' && check) ) { if ( this.checkCondition(check, nextVal) ) {
return Pipe.wrap(op(this.subject)) const appliedVal = op(nextVal)
} if ( typeof appliedVal === 'undefined' ) {
return nextVal
}
return this return appliedVal
}
return nextVal
})
} }
/** /**
@ -112,42 +89,32 @@ export class Pipe<T> {
* @param check * @param check
* @param op * @param op
*/ */
unless(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> { unless(check: PipeCondition<TOut>, op: ReflexivePipeOperator<TOut>): Pipeline<TIn, TOut> {
if ( return new Pipeline((val: TIn) => {
(typeof check === 'function' && check(this.subject)) const nextVal = this.factory(val)
|| (typeof check !== 'function' && check) ) { if ( !this.checkCondition(check, nextVal) ) {
return this const appliedVal = op(nextVal)
} if ( typeof appliedVal === 'undefined' ) {
return nextVal
}
return Pipe.wrap(op(this.subject)) return appliedVal
}
return nextVal
})
} }
/** /**
* Alias of `unless()`. * Apply the pipeline to an input.
* @param check
* @param op
*/ */
whenNot(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> { apply(input: TIn): TOut {
return this.unless(check, op) return this.factory(input)
} }
/** protected checkCondition(check: PipeCondition<TOut>, val: TOut): boolean {
* Get the item in the pipe. return (typeof check === 'function' && check(val))
* || (typeof check !== 'function' && check)
* @example
* ```typescript
* Pipe.wrap(4).get() // => 4
* ```
*/
get(): T {
return this.subject
}
/**
* Get an AsyncPipe with the current item in the pipe.
*/
async(): AsyncPipe<T> {
return AsyncPipe.wrap<T>(this.subject)
} }
} }
@ -280,13 +247,6 @@ export class AsyncPipe<T> {
return this.subject() return this.subject()
} }
/**
* Resolve the value and return it in a sync `Pipe` instance.
*/
async sync(): Promise<Pipe<T>> {
return Pipe.wrap<T>(await this.subject())
}
/** Get the transformed value from the pipe. Allows awaiting the pipe directly. */ /** Get the transformed value from the pipe. Allows awaiting the pipe directly. */
then(): Promise<T> { then(): Promise<T> {
return this.resolve() return this.resolve()

View File

@ -5,7 +5,7 @@ import * as mime from 'mime-types'
import {FileNotFoundError, Filesystem} from './path/Filesystem' import {FileNotFoundError, Filesystem} from './path/Filesystem'
import {Collection} from '../collection/Collection' import {Collection} from '../collection/Collection'
import {Readable, Writable} from 'stream' import {Readable, Writable} from 'stream'
import {Pipe} from './Pipe' import {Pipeline} from './Pipe'
/** /**
* An item that could represent a path. * An item that could represent a path.
@ -533,8 +533,15 @@ export class UniversalPath {
return false return false
} }
/** Get a new Pipe instance wrapping this. */ /**
toPipe(): Pipe<UniversalPath> { * Return a new Pipe of this collection.
return Pipe.wrap(this) */
pipeTo<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return pipeline.apply(this)
}
/** Build and apply a pipeline. */
pipe<TOut>(builder: (pipeline: Pipeline<this, this>) => Pipeline<this, TOut>): TOut {
return builder(Pipeline.id()).apply(this)
} }
} }

View File

@ -4,6 +4,28 @@ export type Awaitable<T> = T | Promise<T>
/** Type alias for something that may be undefined. */ /** Type alias for something that may be undefined. */
export type Maybe<T> = T | undefined export type Maybe<T> = T | undefined
export type Either<T1, T2> = Left<T1> | Right<T2>
export type Left<T> = [T, undefined]
export type Right<T> = [undefined, T]
export function isLeft<T1, T2>(what: Either<T1, T2>): what is Left<T1> {
return typeof what[1] === 'undefined'
}
export function isRight<T1, T2>(what: Either<T1, T2>): what is Right<T2> {
return typeof what[0] === 'undefined'
}
export function left<T>(what: T): Left<T> {
return [what, undefined]
}
export function right<T>(what: T): Right<T> {
return [undefined, what]
}
/** Type alias for a callback that accepts a typed argument. */ /** Type alias for a callback that accepts a typed argument. */
export type ParameterizedCallback<T> = ((arg: T) => any) export type ParameterizedCallback<T> = ((arg: T) => any)
@ -30,3 +52,7 @@ export function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop
export interface TypeTag<S extends string> { export interface TypeTag<S extends string> {
readonly __typeTag: S readonly __typeTag: S
} }
export type PrefixTypeArray<T, TArr extends unknown[]> = [T, ...TArr]
export type SuffixTypeArray<TArr extends unknown[], T> = [...TArr, T]
export type TypeArraySignature<TArr extends unknown[], TReturn> = (...params: TArr) => TReturn

View File

@ -1,17 +1,92 @@
import { z } from 'zod' import { z } from 'zod'
import {InjectionAware} from '../di' import {InjectionAware} from '../di'
import {ErrorWithContext, TypeTag} from '../util'
import {ZodifyRecipient} from './ZodifyRecipient' import {ZodifyRecipient} from './ZodifyRecipient'
import {ZodifyRegistrar} from './ZodifyRegistrar' import {ZodifyRegistrar} from './ZodifyRegistrar'
import {Logging} from '../service/Logging'
/** Type tag for a validated runtime type. */
export type Valid<T> = TypeTag<'@extollo/lib:Valid'> & T
/**
* Error thrown if the schema for a validator cannot be located.
*/
export class InvalidSchemaMappingError extends Error { export class InvalidSchemaMappingError extends Error {
constructor(message = 'Unable to resolve schema for validator.') { constructor(message = 'Unable to resolve schema for validator.') {
super(message) super(message)
} }
} }
/**
* Interface defining validation error messages.
*/
export interface ValidationFormErrors<T> {
formErrors: string[],
fieldErrors: {
[k in keyof T]: string[]
}
}
/**
* Error thrown when data validation has failed.
*/
export class ValidationError<T> extends ErrorWithContext {
constructor(
public readonly formErrors: ValidationFormErrors<T>,
message = 'Invalid form data',
) {
super(message)
}
}
/**
* Validates input data against a schema at runtime.
*/
export class Validator<T> extends InjectionAware implements ZodifyRecipient { export class Validator<T> extends InjectionAware implements ZodifyRecipient {
__exZodifiedSchemata: number[] = [] __exZodifiedSchemata: number[] = []
/**
* Parse the input data against the schema.
* @throws ValidationError
* @param from
*/
public parse(from: unknown): Valid<T> {
try {
return this.getZod().parse(from) as Valid<T>
} catch (e: unknown) {
if ( e instanceof z.ZodError ) {
throw new ValidationError<T>(e.formErrors as ValidationFormErrors<T>)
}
throw e
}
}
/**
* Typeguard for the input schema.
* @param what
*/
public is(what: unknown): what is Valid<T> {
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>(Logging)
}
/** Get the Zod schema. */
protected getZod(): z.ZodType<T> { protected getZod(): z.ZodType<T> {
// eslint-disable-next-line no-underscore-dangle // eslint-disable-next-line no-underscore-dangle
if ( this.__exZodifiedSchemata.length < 1 ) { if ( this.__exZodifiedSchemata.length < 1 ) {