Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 395e8e4d1c | |||
| f6a7cac05c | |||
| 25265b5560 | |||
| a779ec1d09 | |||
| bea48602f5 | |||
| 445f16d973 | |||
| 351a2e14b8 | |||
| b42e91533a | |||
| 78cb26fcb2 | |||
| 514a578260 | |||
| 3d7d583367 | |||
| 6f66126d38 | |||
| 10b3e1ecc3 | |||
| 795adac68b | |||
| ca348b2ff6 | |||
| 508d92f759 | |||
| a590d78155 | |||
| dbe48ea8a5 | |||
| 467721f775 | |||
| 153f8f7685 | |||
| ba87ea32c3 | |||
| 737d06f6f0 | |||
| 6ee3e2a729 | |||
| 1288e51de0 | |||
| 1fde692a65 | |||
| cdecb7e628 | |||
| 8f08b94f74 | |||
| a039b1ff25 | |||
| 70d67c2730 | |||
| 0774deea91 | |||
| 16e5fa00aa | |||
| e098a5edb7 | |||
| 6d1cf18680 | |||
| 506fb55c74 | |||
| cfd555723b | |||
| 32050cb2ce | |||
| dc16dfdb81 | |||
| 8cf19792a6 | |||
| 9b8333295f | |||
| 5ffb91329e | |||
| b105a61ca2 | |||
| 9204a02450 | |||
| 463076d182 | |||
| b5eb407b55 | |||
| 0ed096c782 | |||
| 5175d64e36 | |||
| bd7d6a2dbd | |||
| d251f8bc15 | |||
| bf4a675faa |
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.5.12",
|
||||
"version": "0.9.23",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -9,10 +9,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@atao60/fse-cli": "^0.1.6",
|
||||
"@extollo/ui": "^0.1.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/ioredis": "^4.26.6",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
@@ -29,6 +31,7 @@
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"ioredis": "^4.27.6",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
@@ -44,10 +47,11 @@
|
||||
"typedoc-plugin-pages-fork": "^0.0.1",
|
||||
"typedoc-plugin-sourcefile-url": "^1.0.6",
|
||||
"typescript": "^4.2.3",
|
||||
"uuid": "^8.3.2"
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
|
||||
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build",
|
||||
@@ -67,14 +71,25 @@
|
||||
"author": "garrettmills <shout@garrettmills.dev>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/sinon": "^10.0.6",
|
||||
"@types/wtfnode": "^0.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||
"@typescript-eslint/parser": "^4.26.0",
|
||||
"eslint": "^7.27.0"
|
||||
"chai": "^4.3.4",
|
||||
"eslint": "^7.27.0",
|
||||
"mocha": "^9.1.3",
|
||||
"sinon": "^12.0.1",
|
||||
"wtfnode": "^0.9.1"
|
||||
},
|
||||
"extollo": {
|
||||
"discover": true,
|
||||
"units": {
|
||||
"discover": false
|
||||
},
|
||||
"recursiveDependencies": {
|
||||
"discover": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
656
pnpm-lock.yaml
generated
656
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,19 @@
|
||||
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {Injectable, Inject, StaticInstantiable} from '../di'
|
||||
import {Logging} from '../service/Logging'
|
||||
import {Middlewares} from '../service/Middlewares'
|
||||
import {CanonicalResolver} from '../service/Canonical'
|
||||
import {Middleware} from '../http/routing/Middleware'
|
||||
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
||||
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||
import {Middlewares} from '../service/Middlewares'
|
||||
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
||||
import {ViewEngine} from '../views/ViewEngine'
|
||||
import {SecurityContext} from './context/SecurityContext'
|
||||
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
|
||||
import {Config} from '../service/Config'
|
||||
import {ErrorWithContext, hasOwnProperty} from '../util'
|
||||
import {Route} from '../http/routing/Route'
|
||||
|
||||
/**
|
||||
* Unit class that bootstraps the authentication framework.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Authentication extends Unit {
|
||||
@Inject()
|
||||
@@ -19,21 +22,55 @@ export class Authentication extends Unit {
|
||||
@Inject()
|
||||
protected readonly middleware!: Middlewares
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.container()
|
||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||
|
||||
this.container().onResolve<ViewEngine>(ViewEngine)
|
||||
.then((engine: ViewEngine) => {
|
||||
engine.registerGlobalFactory('user', req => {
|
||||
return () => req?.make<SecurityContext>(SecurityContext)?.getUser()
|
||||
})
|
||||
})
|
||||
|
||||
const config = this.config.get('auth.providers', {})
|
||||
const middleware = this.config.get('auth.middleware', SessionAuthMiddleware)
|
||||
|
||||
if ( !(middleware?.prototype instanceof Middleware) ) {
|
||||
throw new ErrorWithContext('Auth middleware must extend Middleware base class', {
|
||||
providedValue: middleware,
|
||||
configKey: 'auth.middleware',
|
||||
})
|
||||
}
|
||||
|
||||
for ( const name in config ) {
|
||||
if ( !hasOwnProperty(config, name) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ( this.providers[name] ) {
|
||||
this.logging.warn(`Registering duplicate authentication provider: ${name}`)
|
||||
}
|
||||
|
||||
this.logging.verbose(`Registered authentication provider: ${name}`)
|
||||
this.providers[name] = this.make(config[name].driver, name, config[name].config)
|
||||
|
||||
Route.group(`/auth/${name}`, () => {
|
||||
this.providers[name].routes()
|
||||
}).pre(middleware)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the canonical namespace resolver for auth middleware.
|
||||
* @protected
|
||||
*/
|
||||
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
||||
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {
|
||||
return (key: string) => {
|
||||
return ({
|
||||
web: SessionAuthMiddleware,
|
||||
required: AuthRequiredMiddleware,
|
||||
guest: GuestRequiredMiddleware,
|
||||
web: SessionAuthMiddleware,
|
||||
})[key]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
private authenticatedUser?: Authenticatable
|
||||
|
||||
constructor(
|
||||
/** The repository from which to draw users. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
|
||||
/** The name of this context. */
|
||||
public readonly name: string,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Called when the context is created. Can be used by child-classes to do setup work.
|
||||
*/
|
||||
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Authenticate the given user, without persisting the authentication.
|
||||
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
|
||||
* @param user
|
||||
*/
|
||||
async authenticateOnce(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the given user and persist the authentication.
|
||||
* @param user
|
||||
*/
|
||||
async authenticate(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.persist()
|
||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user based on their credentials.
|
||||
* If the credentials are valid, the user will be authenticated, but the authentication
|
||||
* will not be persisted. That is, when the lifecycle ends, the user will be
|
||||
* unauthenticated implicitly.
|
||||
* @param credentials
|
||||
*/
|
||||
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticateOnce(user)
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user based on their credentials.
|
||||
* If the credentials are valid, the user will be authenticated and the
|
||||
* authentication will be persisted.
|
||||
* @param credentials
|
||||
*/
|
||||
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticate(user)
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, but do not persist the change.
|
||||
*/
|
||||
async flushOnce(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, and persist the change.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.persist()
|
||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assuming a user is still authenticated in the context,
|
||||
* try to look up and fill in the user.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
const credentials = await this.getCredentials()
|
||||
this.logging.debug('resume:')
|
||||
this.logging.debug(credentials)
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the current state of the security context to whatever storage
|
||||
* medium the context's host provides.
|
||||
*/
|
||||
abstract persist(): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Get the credentials for the current user from whatever storage medium
|
||||
* the context's host provides.
|
||||
*/
|
||||
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
*/
|
||||
getUser(): Maybe<Authenticatable> {
|
||||
return this.authenticatedUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
this.logging.debug('hasUser?')
|
||||
this.logging.debug(this.authenticatedUser)
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Valid, ValidationError} from '../../forms'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class BasicLoginController extends Controller {
|
||||
public static routes({ enableRegistration = true } = {}): void {
|
||||
Route.group('auth', () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login')
|
||||
|
||||
Route.post('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login.attempt')
|
||||
|
||||
Route.any('logout', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogout()
|
||||
})
|
||||
.pre('@auth:required')
|
||||
.alias('@auth.logout')
|
||||
|
||||
if ( enableRegistration ) {
|
||||
Route.get('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getRegistration()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register')
|
||||
|
||||
Route.post('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptRegister()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register.attempt')
|
||||
}
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return this.getLoginView()
|
||||
}
|
||||
|
||||
public getRegistration(): ResponseFactory {
|
||||
return this.getRegistrationView()
|
||||
}
|
||||
|
||||
public async attemptLogin(): Promise<ResponseObject> {
|
||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.attempt(data)
|
||||
if ( user ) {
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
}
|
||||
|
||||
return this.getLoginView(['Invalid username/password.'])
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getLoginView(e.errors.all())
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async attemptLogout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return this.getMessageView('You have been logged out.')
|
||||
}
|
||||
|
||||
public async attemptRegister(): Promise<ResponseObject> {
|
||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.repository.createByCredentials(data)
|
||||
await this.security.authenticate(user)
|
||||
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(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
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:login', {
|
||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:register', {
|
||||
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getMessageView(message: string): ResponseFactory {
|
||||
return view('@extollo:auth:message', {
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
Str.alphaNum,
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(8),
|
||||
Str.confirmed,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,51 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||
import {Instantiable, isInstantiable} from '../di'
|
||||
import {AuthenticatableRepository} from './types'
|
||||
import {hasOwnProperty} from '../util'
|
||||
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
|
||||
import {Middleware} from '../http/routing/Middleware'
|
||||
|
||||
/**
|
||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||
*/
|
||||
export interface AuthenticatableRepositoryMapping {
|
||||
orm: Instantiable<ORMUserRepository>,
|
||||
}
|
||||
|
||||
/**
|
||||
* String mapping of AuthenticatableRepository implementations.
|
||||
*/
|
||||
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
||||
orm: ORMUserRepository,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for making the auth config type-safe.
|
||||
*/
|
||||
export interface AuthConfig {
|
||||
repositories: {
|
||||
session: keyof AuthenticatableRepositoryMapping,
|
||||
},
|
||||
sources?: {
|
||||
[key: string]: OAuth2LoginConfig,
|
||||
export interface AuthenticationConfig {
|
||||
storage: Instantiable<AuthenticatableRepository>,
|
||||
middleware?: Instantiable<Middleware>,
|
||||
providers?: {
|
||||
[key: string]: {
|
||||
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
|
||||
config: LoginProviderConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig {
|
||||
if ( typeof what !== 'object' || !what ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( typeof what.providers !== 'object' ) {
|
||||
return false
|
||||
}
|
||||
|
||||
for ( const key in what.providers ) {
|
||||
if ( !hasOwnProperty(what.providers, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const source = what.providers[key]
|
||||
if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
119
src/auth/context/SecurityContext.ts
Normal file
119
src/auth/context/SecurityContext.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Awaitable, HTTPStatus, Maybe} from '../../util'
|
||||
import {Authenticatable, AuthenticatableRepository} from '../types'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
||||
import {Bus} from '../../support/bus'
|
||||
import {HTTPError} from '../../http/HTTPError'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: Bus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
protected authenticatedUser?: Authenticatable
|
||||
|
||||
constructor(
|
||||
/** The repository where users are persisted. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
|
||||
/** The name of this context. */
|
||||
public readonly name: string,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Called when the context is created. Can be used by child-classes to do setup work.
|
||||
*/
|
||||
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Authenticate the given user, without persisting the authentication.
|
||||
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
|
||||
* @param user
|
||||
*/
|
||||
async authenticateOnce(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.push(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the given user and persist the authentication.
|
||||
* @param user
|
||||
*/
|
||||
async authenticate(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.persist()
|
||||
await this.bus.push(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, but do not persist the change.
|
||||
*/
|
||||
async flushOnce(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.bus.push(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, and persist the change.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.persist()
|
||||
await this.bus.push(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assuming a user is still authenticated in the context,
|
||||
* try to look up and fill in the user.
|
||||
*/
|
||||
abstract resume(): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Write the current state of the security context to whatever storage
|
||||
* medium the context's host provides.
|
||||
*/
|
||||
abstract persist(): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
*/
|
||||
getUser(): Maybe<Authenticatable> {
|
||||
return this.authenticatedUser
|
||||
}
|
||||
|
||||
/** Get the current user or throw an authorization error. */
|
||||
user(): Authenticatable {
|
||||
if ( !this.hasUser() ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const user = this.getUser()
|
||||
if ( !user ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
39
src/auth/context/SessionSecurityContext.ts
Normal file
39
src/auth/context/SessionSecurityContext.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {SecurityContext} from './SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
||||
|
||||
export const EXTOLLO_AUTH_SESSION_KEY = '@extollo:auth.securityIdentifier'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionSecurityContext extends SecurityContext {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
constructor(
|
||||
/** The repository from which to draw users. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
) {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
persist(): Awaitable<void> {
|
||||
this.session.set(EXTOLLO_AUTH_SESSION_KEY, this.getUser()?.getIdentifier())
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
const identifier = this.session.get(EXTOLLO_AUTH_SESSION_KEY)
|
||||
if ( identifier ) {
|
||||
const user = await this.repository.getByIdentifier(identifier)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionSecurityContext extends SecurityContext {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
constructor(
|
||||
/** The repository from which to draw users. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
) {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||
return {
|
||||
identifier: '',
|
||||
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
persist(): Awaitable<void> {
|
||||
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
|
||||
}
|
||||
}
|
||||
12
src/auth/event/AuthenticationEvent.ts
Normal file
12
src/auth/event/AuthenticationEvent.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {Authenticatable} from '../types'
|
||||
import {BaseEvent} from '../../support/bus'
|
||||
|
||||
export abstract class AuthenticationEvent extends BaseEvent {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,8 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||
|
||||
/**
|
||||
* Event fired when a user is authenticated.
|
||||
*/
|
||||
export class UserAuthenticatedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
export class UserAuthenticatedEvent extends AuthenticationEvent {
|
||||
public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||
|
||||
/**
|
||||
* Event fired when a security context for a given user is resumed.
|
||||
* Event raised when a user is re-authenticated to a security context
|
||||
*/
|
||||
export class UserAuthenticationResumedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
|
||||
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||
|
||||
/**
|
||||
* Event fired when a user is unauthenticated.
|
||||
*/
|
||||
export class UserFlushedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
export class UserFlushedEvent extends AuthenticationEvent {
|
||||
public readonly eventName = '@extollo/lib:UserFlushedEvent'
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import {Controller} from '../../../http/Controller'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {ErrorWithContext} from '../../../util'
|
||||
import {OAuth2Repository} from './OAuth2Repository'
|
||||
import {json} from '../../../http/response/JSONResponseFactory'
|
||||
|
||||
export interface OAuth2LoginConfig {
|
||||
name: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUrl: string,
|
||||
authorizationCodeField: string,
|
||||
tokenEndpoint: string,
|
||||
tokenEndpointMapping?: {
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
grantType?: string,
|
||||
codeKey?: string,
|
||||
},
|
||||
tokenEndpointResponseMapping?: {
|
||||
token?: string,
|
||||
expiresIn?: string,
|
||||
expiresAt?: string,
|
||||
},
|
||||
userEndpoint: string,
|
||||
userEndpointResponseMapping?: {
|
||||
identifier?: string,
|
||||
display?: string,
|
||||
},
|
||||
}
|
||||
|
||||
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
|
||||
return (
|
||||
Boolean(what)
|
||||
&& typeof (what as any).name === 'string'
|
||||
&& typeof (what as any).clientId === 'string'
|
||||
&& typeof (what as any).clientSecret === 'string'
|
||||
&& typeof (what as any).redirectUrl === 'string'
|
||||
&& typeof (what as any).authorizationCodeField === 'string'
|
||||
&& typeof (what as any).tokenEndpoint === 'string'
|
||||
&& typeof (what as any).userEndpoint === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2LoginController extends Controller {
|
||||
public static routes(configName: string): void {
|
||||
Route.group(`/auth/${configName}`, () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
|
||||
return controller.getLogin()
|
||||
}).pre('@auth:guest')
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
protected readonly configName: string,
|
||||
) {
|
||||
super(request)
|
||||
}
|
||||
|
||||
public async getLogin(): Promise<ResponseObject> {
|
||||
const repo = this.getRepository()
|
||||
if ( repo.shouldRedirect() ) {
|
||||
return repo.redirect()
|
||||
}
|
||||
|
||||
// We were redirected from the auth source
|
||||
const user = await repo.redeem()
|
||||
return json(user)
|
||||
}
|
||||
|
||||
protected getRepository(): OAuth2Repository {
|
||||
return this.request.make(OAuth2Repository, this.getConfig())
|
||||
}
|
||||
|
||||
protected getConfig(): OAuth2LoginConfig {
|
||||
const config = this.config.get(`auth.sources.${this.configName}`)
|
||||
if ( !isOAuth2LoginConfig(config) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 source config.', {
|
||||
configName: this.configName,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
@@ -1,155 +0,0 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableRepository,
|
||||
} from '../../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {
|
||||
Awaitable,
|
||||
dataGetUnsafe,
|
||||
fetch,
|
||||
Maybe,
|
||||
MethodNotSupportedError,
|
||||
UniversalPath,
|
||||
universalPath,
|
||||
uuid4,
|
||||
} from '../../../util'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Session} from '../../../http/session/Session'
|
||||
import {ResponseObject} from '../../../http/routing/Route'
|
||||
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {OAuth2User} from './OAuth2User'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Repository implements AuthenticatableRepository {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor(
|
||||
protected readonly config: OAuth2LoginConfig,
|
||||
) { }
|
||||
|
||||
public createByCredentials(): Awaitable<Authenticatable> {
|
||||
throw new MethodNotSupportedError()
|
||||
}
|
||||
|
||||
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
|
||||
return this.getAuthenticatableFromBearer(credentials.credential)
|
||||
}
|
||||
|
||||
getByIdentifier(): Awaitable<Maybe<Authenticatable>> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
public getRedirectUrl(state?: string): UniversalPath {
|
||||
const url = universalPath(this.config.redirectUrl)
|
||||
if ( state ) {
|
||||
url.query.append('state', state)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public getTokenEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.tokenEndpoint)
|
||||
}
|
||||
|
||||
public getUserEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.userEndpoint)
|
||||
}
|
||||
|
||||
public async redeem(): Promise<Maybe<OAuth2User>> {
|
||||
if ( !this.stateIsValid() ) {
|
||||
return // FIXME throw
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
|
||||
if ( this.config.tokenEndpointMapping ) {
|
||||
if ( this.config.tokenEndpointMapping.clientId ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.clientSecret ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.codeKey ) {
|
||||
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.grantType ) {
|
||||
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
|
||||
}
|
||||
}
|
||||
|
||||
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
|
||||
|
||||
const response = await fetch(this.getTokenEndpoint().toRemote, {
|
||||
method: 'post',
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
this.logging.debug(data)
|
||||
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
|
||||
|
||||
this.logging.debug(bearer)
|
||||
if ( !bearer || typeof bearer !== 'string' ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return this.getAuthenticatableFromBearer(bearer)
|
||||
}
|
||||
|
||||
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
|
||||
const response = await fetch(this.getUserEndpoint().toRemote, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${bearer}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return new OAuth2User(data, this.config)
|
||||
}
|
||||
|
||||
public stateIsValid(): boolean {
|
||||
const correctState = this.session.get('extollo.auth.oauth2.state', '')
|
||||
const inputState = this.request.input('state') || ''
|
||||
return correctState === inputState
|
||||
}
|
||||
|
||||
public shouldRedirect(): boolean {
|
||||
const codeField = this.config.authorizationCodeField
|
||||
const code = this.request.input(codeField)
|
||||
return !code
|
||||
}
|
||||
|
||||
public async redirect(): Promise<ResponseObject> {
|
||||
const state = uuid4()
|
||||
await this.session.set('extollo.auth.oauth2.state', state)
|
||||
return temporary(this.getRedirectUrl(state).toRemote)
|
||||
}
|
||||
}
|
||||
50
src/auth/external/oauth2/OAuth2User.ts
vendored
50
src/auth/external/oauth2/OAuth2User.ts
vendored
@@ -1,50 +0,0 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
|
||||
|
||||
export class OAuth2User implements Authenticatable {
|
||||
protected displayField: string
|
||||
|
||||
protected identifierField: string
|
||||
|
||||
constructor(
|
||||
protected data: {[key: string]: any},
|
||||
config: OAuth2LoginConfig,
|
||||
) {
|
||||
this.displayField = config.userEndpointResponseMapping?.display || 'name'
|
||||
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
|
||||
}
|
||||
|
||||
getDisplayIdentifier(): string {
|
||||
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
|
||||
}
|
||||
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
isOAuth2User: true,
|
||||
data: this.data,
|
||||
displayField: this.displayField,
|
||||
identifierField: this.identifierField,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> {
|
||||
if (
|
||||
!state.isOAuth2User
|
||||
|| typeof state.data !== 'object'
|
||||
|| state.data === null
|
||||
|| typeof state.displayField !== 'string'
|
||||
|| typeof state.identifierField !== 'string'
|
||||
) {
|
||||
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
|
||||
}
|
||||
|
||||
this.data = state.data
|
||||
this.identifierField = state.identifierField
|
||||
this.displayField = state.identifierField
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,42 @@
|
||||
export * from './types'
|
||||
export * from './AuthenticatableAlreadyExistsError'
|
||||
export * from './NotAuthorizedError'
|
||||
export * from './Authentication'
|
||||
|
||||
export * from './SecurityContext'
|
||||
export * from './context/SecurityContext'
|
||||
export * from './context/SessionSecurityContext'
|
||||
|
||||
export * from './event/AuthenticationEvent'
|
||||
export * from './event/UserAuthenticatedEvent'
|
||||
export * from './event/UserFlushedEvent'
|
||||
export * from './event/UserAuthenticationResumedEvent'
|
||||
|
||||
export * from './contexts/SessionSecurityContext'
|
||||
|
||||
export * from './orm/ORMUser'
|
||||
export * from './orm/ORMUserRepository'
|
||||
export * from './event/UserFlushedEvent'
|
||||
|
||||
export * from './middleware/AuthRequiredMiddleware'
|
||||
export * from './middleware/GuestRequiredMiddleware'
|
||||
export * from './middleware/SessionAuthMiddleware'
|
||||
|
||||
export * from './Authentication'
|
||||
export * from './provider/basic/BasicLoginAttempt'
|
||||
export * from './provider/basic/BasicLoginProvider'
|
||||
export * from './provider/basic/BasicRegistrationAttempt'
|
||||
|
||||
export * from './provider/oauth/OAuth2LoginProvider'
|
||||
export * from './provider/oauth/CoreIDLoginProvider'
|
||||
|
||||
export * from './serial/AuthenticationEventSerializer'
|
||||
|
||||
export * from './repository/orm/ORMUser'
|
||||
export * from './repository/orm/ORMUserRepository'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './basic-ui/BasicLoginFormRequest'
|
||||
export * from './basic-ui/BasicLoginController'
|
||||
|
||||
export * from './external/oauth2/OAuth2LoginController'
|
||||
export * from './server/types'
|
||||
export * from './server/models/OAuth2TokenModel'
|
||||
export * from './server/repositories/ConfigClientRepository'
|
||||
export * from './server/repositories/ConfigScopeRepository'
|
||||
export * from './server/repositories/ClientRepositoryFactory'
|
||||
export * from './server/repositories/ScopeRepositoryFactory'
|
||||
export * from './server/repositories/ORMTokenRepository'
|
||||
export * from './server/repositories/TokenRepositoryFactory'
|
||||
export * from './server/repositories/CacheRedemptionCodeRepository'
|
||||
export * from './server/repositories/RedemptionCodeRepositoryFactory'
|
||||
export * from './server/OAuth2Server'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
@@ -9,6 +9,8 @@ import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Session} from '../../http/session/Session'
|
||||
|
||||
// TODO handle JSON and non-web
|
||||
|
||||
@Injectable()
|
||||
export class AuthRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
@@ -22,10 +24,10 @@ export class AuthRequiredMiddleware extends Middleware {
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( !this.security.hasUser() ) {
|
||||
this.session.set('auth.intention', this.request.url)
|
||||
this.session.set('@extollo:auth.intention', this.request.url)
|
||||
|
||||
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
||||
if ( this.routing.hasNamedRoute('@auth:login') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth:login').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
@@ -8,6 +8,8 @@ import {HTTPStatus} from '../../util'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
|
||||
// TODO handle JSON and non-web
|
||||
|
||||
@Injectable()
|
||||
export class GuestRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {Inject, Injectable, Instantiable} from '../../di'
|
||||
import {Config} from '../../service/Config'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ORMUserRepository} from '../orm/ORMUserRepository'
|
||||
import {AuthConfig, AuthenticatableRepositories} from '../config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {Maybe} from '../../util'
|
||||
import {AuthenticationConfig, isAuthenticationConfig} from '../config'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {SessionSecurityContext} from '../context/SessionSecurityContext'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
|
||||
/**
|
||||
* Injects a SessionSecurityContext into the request and attempts to
|
||||
@@ -22,7 +22,7 @@ export class SessionAuthMiddleware extends Middleware {
|
||||
protected readonly logging!: Logging
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying session auth middleware...')
|
||||
this.logging.debug('Applying session auth middleware.')
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
@@ -33,8 +33,12 @@ export class SessionAuthMiddleware extends Middleware {
|
||||
* @protected
|
||||
*/
|
||||
protected getRepository(): AuthenticatableRepository {
|
||||
const config: AuthConfig | undefined = this.config.get('auth')
|
||||
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
|
||||
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
|
||||
const config: Maybe<AuthenticationConfig> = this.config.get('auth')
|
||||
if ( !isAuthenticationConfig(config) ) {
|
||||
throw new TypeError('Invalid authentication config.')
|
||||
}
|
||||
|
||||
const repo: Instantiable<AuthenticatableRepository> = config.storage
|
||||
return this.make(repo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', id)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to look up a user by the credentials provided.
|
||||
* If a securityIdentifier is specified, look up the user by username.
|
||||
* If username/password are specified, look up the user and verify the password.
|
||||
* @param credentials
|
||||
*/
|
||||
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
if ( !credentials.identifier && credentials.credential ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.credential)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.identifier && credentials.credential ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.identifier)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||
if ( await this.getByCredentials(credentials) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
identifier: credentials.identifier,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.make(ORMUser)
|
||||
user.username = credentials.identifier
|
||||
await user.setPassword(credentials.credential)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
74
src/auth/provider/LoginProvider.ts
Normal file
74
src/auth/provider/LoginProvider.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {GuestRequiredMiddleware} from '../middleware/GuestRequiredMiddleware'
|
||||
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
|
||||
import {Session} from '../../http/session/Session'
|
||||
|
||||
export interface LoginProviderConfig {
|
||||
default: boolean,
|
||||
allow?: {
|
||||
login?: boolean,
|
||||
registration?: boolean,
|
||||
},
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export abstract class LoginProvider<TConfig extends LoginProviderConfig> {
|
||||
@Inject()
|
||||
protected readonly request!: RequestLocalStorage
|
||||
|
||||
protected get security(): SecurityContext {
|
||||
return this.request.get().make(SecurityContext)
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected name: string,
|
||||
protected config: TConfig,
|
||||
) {}
|
||||
|
||||
public routes(): void {
|
||||
Route.get('login')
|
||||
.alias(`@auth:${this.name}:login`)
|
||||
.pipe(line => line.when(this.config.default, route => route.alias('@auth:login')))
|
||||
.pre(GuestRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.handledBy(this.login.bind(this))
|
||||
|
||||
Route.any('logout')
|
||||
.alias(`@auth:${this.name}:logout`)
|
||||
.pipe(line => line.when(this.config.default, route => route.alias('@auth:logout')))
|
||||
.pre(AuthRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.handledBy(this.logout.bind(this))
|
||||
|
||||
Route.get('register')
|
||||
.alias(`@auth:${this.name}:register`)
|
||||
.pipe(line => line.when(this.config.default, route => route.alias('@auth:register')))
|
||||
.pre(GuestRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.handledBy(this.registration.bind(this))
|
||||
}
|
||||
|
||||
public abstract login(request: Request): ResponseObject
|
||||
|
||||
public abstract logout(request: Request): ResponseObject
|
||||
|
||||
public registration(request: Request): ResponseObject {
|
||||
return this.login(request)
|
||||
}
|
||||
|
||||
protected redirectToIntendedRoute(): ResponseObject {
|
||||
const intent = this.request
|
||||
.get()
|
||||
.make<Session>(Session)
|
||||
.safe('@extollo:auth.intention')
|
||||
.or('/')
|
||||
.string()
|
||||
|
||||
return redirect(intent)
|
||||
}
|
||||
}
|
||||
8
src/auth/provider/basic/BasicLoginAttempt.ts
Normal file
8
src/auth/provider/basic/BasicLoginAttempt.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type BasicLoginAttempt = z.infer<typeof BasicLoginAttemptType>
|
||||
|
||||
export const BasicLoginAttemptType = z.object({
|
||||
username: z.string().nonempty(),
|
||||
password: z.string().nonempty(),
|
||||
})
|
||||
75
src/auth/provider/basic/BasicLoginProvider.ts
Normal file
75
src/auth/provider/basic/BasicLoginProvider.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {view} from '../../../http/response/ViewResponseFactory'
|
||||
import {Valid, Validator} from '../../../validation/Validator'
|
||||
import {BasicLoginAttempt, BasicLoginAttemptType} from './BasicLoginAttempt'
|
||||
import {BasicRegistrationAttempt, BasicRegistrationAttemptType} from './BasicRegistrationAttempt'
|
||||
|
||||
/**
|
||||
* LoginProvider implementation that provides basic username/password login.
|
||||
*/
|
||||
export class BasicLoginProvider extends LoginProvider<LoginProviderConfig> {
|
||||
public routes(): void {
|
||||
super.routes()
|
||||
|
||||
Route.post('/login')
|
||||
.alias(`@auth:${this.name}:login.submit`)
|
||||
.input(Validator.fromSchema<BasicLoginAttempt>(BasicLoginAttemptType))
|
||||
.handledBy((...p) => this.attemptLogin(...p))
|
||||
|
||||
Route.post('/register')
|
||||
.alias(`@auth:${this.name}:register.submit`)
|
||||
.input(Validator.fromSchema<BasicRegistrationAttempt>(BasicRegistrationAttemptType))
|
||||
.handledBy((...p) => this.attemptRegistration(...p))
|
||||
}
|
||||
|
||||
public login(): ResponseObject {
|
||||
return view('@extollo:auth:login')
|
||||
}
|
||||
|
||||
public async logout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return view('@extollo:auth:logout')
|
||||
}
|
||||
|
||||
public registration(): ResponseObject {
|
||||
return view('@extollo:auth:register')
|
||||
}
|
||||
|
||||
/** Attempt to authenticate the user with a username/password. */
|
||||
public async attemptLogin(attempt: Valid<BasicLoginAttempt>): Promise<ResponseObject> {
|
||||
const user = await this.security.repository.getByIdentifier(attempt.username)
|
||||
if ( !user ) {
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
if ( !(await user.validateCredential(attempt.password)) ) {
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
await this.security.authenticate(user)
|
||||
return this.redirectToIntendedRoute()
|
||||
}
|
||||
|
||||
/** Attempt to register the user with a username/password. */
|
||||
public async attemptRegistration(attempt: Valid<BasicRegistrationAttempt>): Promise<ResponseObject> {
|
||||
const existingUser = await this.security.repository.getByIdentifier(attempt.username)
|
||||
if ( existingUser ) {
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
if ( attempt.password !== attempt.passwordConfirmation ) {
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
const user = await this.security.repository.createFromCredentials(attempt.username, attempt.password)
|
||||
;(user as any).firstName = attempt.firstName
|
||||
;(user as any).lastName = attempt.lastName
|
||||
if ( typeof (user as any).save === 'function' ) {
|
||||
await (user as any).save()
|
||||
}
|
||||
|
||||
await this.security.authenticate(user)
|
||||
return this.redirectToIntendedRoute()
|
||||
}
|
||||
}
|
||||
19
src/auth/provider/basic/BasicRegistrationAttempt.ts
Normal file
19
src/auth/provider/basic/BasicRegistrationAttempt.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type BasicRegistrationAttempt = z.infer<typeof BasicRegistrationAttemptType>
|
||||
|
||||
export const BasicRegistrationAttemptType = z.object({
|
||||
firstName: z.string().nonempty(),
|
||||
|
||||
lastName: z.string().nonempty(),
|
||||
|
||||
username: z.string().nonempty(),
|
||||
|
||||
password: z.string()
|
||||
.nonempty()
|
||||
.min(8),
|
||||
|
||||
passwordConfirmation: z.string()
|
||||
.nonempty()
|
||||
.min(8),
|
||||
})
|
||||
101
src/auth/provider/oauth/CoreIDLoginProvider.ts
Normal file
101
src/auth/provider/oauth/CoreIDLoginProvider.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import {OAuth2LoginProvider, OAuth2LoginProviderConfig} from './OAuth2LoginProvider'
|
||||
import {Authenticatable} from '../../types'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {ErrorWithContext, uuid4, fetch} from '../../../util'
|
||||
|
||||
/**
|
||||
* OAuth2LoginProvider implementation that authenticates users against a
|
||||
* Starship CoreID server.
|
||||
*/
|
||||
export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProviderConfig> {
|
||||
protected async callback(request: Request): Promise<Authenticatable> {
|
||||
// Get authentication_code from the request
|
||||
const code = String(request.input('code') || '')
|
||||
if ( !code ) {
|
||||
throw new ErrorWithContext('Unable to authenticate user: missing login code', {
|
||||
input: request.input(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get OAuth2 token from CoreID
|
||||
const token = await this.getToken(code)
|
||||
|
||||
// Get user from endpoint
|
||||
const userData = await this.getUserData(token)
|
||||
|
||||
// Return authenticatable instance
|
||||
const existing = await this.security.repository.getByIdentifier(userData.uid)
|
||||
if ( existing ) {
|
||||
this.updateUser(existing, userData)
|
||||
return existing
|
||||
}
|
||||
|
||||
const user = await this.security.repository.createFromCredentials(userData.uid, uuid4())
|
||||
this.updateUser(user, userData)
|
||||
return user
|
||||
}
|
||||
|
||||
/** Given an access token, look up the associated user's information. */
|
||||
protected async getUserData(token: string): Promise<any> {
|
||||
const userResponse = await fetch(
|
||||
this.config.userUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const userData: any = await userResponse.json()
|
||||
if ( !userData?.data?.uid ) {
|
||||
throw new ErrorWithContext('Unable to extract user from response', {
|
||||
userData,
|
||||
})
|
||||
}
|
||||
|
||||
return userData.data
|
||||
}
|
||||
|
||||
/** Given a login code, redeem it for an access token. */
|
||||
protected async getToken(code: string): Promise<string> {
|
||||
const body: string[] = [
|
||||
'code=' + encodeURIComponent(code),
|
||||
'client_id=' + encodeURIComponent(this.config.clientId),
|
||||
'client_secret=' + encodeURIComponent(this.config.clientSecret),
|
||||
'grant_type=authorization_code',
|
||||
]
|
||||
|
||||
const response = await fetch(
|
||||
this.config.tokenUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.join('&'),
|
||||
},
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
const token = (data as any).access_token
|
||||
if ( !token ) {
|
||||
throw new ErrorWithContext('Unable to obtain access token from response', {
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
return String(token)
|
||||
}
|
||||
|
||||
/** Update values on the Authenticatable from user data. */
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
protected updateUser(user: any, data: any): void {
|
||||
user.firstName = data.first_name
|
||||
user.lastName = data.last_name
|
||||
user.email = data.email
|
||||
user.tagline = data.tagline
|
||||
user.photoUrl = data.profile_photo
|
||||
}
|
||||
}
|
||||
98
src/auth/provider/oauth/OAuth2LoginProvider.ts
Normal file
98
src/auth/provider/oauth/OAuth2LoginProvider.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Routing} from '../../../service/Routing'
|
||||
import {GuestRequiredMiddleware} from '../../middleware/GuestRequiredMiddleware'
|
||||
import {redirect} from '../../../http/response/RedirectResponseFactory'
|
||||
import {view} from '../../../http/response/ViewResponseFactory'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {Awaitable} from '../../../util'
|
||||
import {Authenticatable} from '../../types'
|
||||
|
||||
export interface OAuth2LoginProviderConfig extends LoginProviderConfig {
|
||||
displayName: string,
|
||||
clientId: string|number
|
||||
clientSecret: string
|
||||
loginUrl: string
|
||||
loginMessage?: string
|
||||
logoutUrl?: string
|
||||
tokenUrl: string,
|
||||
userUrl: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginProvider implementation for OAuth2-based logins.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class OAuth2LoginProvider<TConfig extends OAuth2LoginProviderConfig> extends LoginProvider<TConfig> {
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
public routes(): void {
|
||||
super.routes()
|
||||
|
||||
Route.any('redirect')
|
||||
.alias(`@auth:${this.name}:redirect`)
|
||||
.pre(GuestRequiredMiddleware)
|
||||
.handledBy(() => redirect(this.getLoginUrl()))
|
||||
|
||||
Route.any('callback')
|
||||
.alias(`@auth:${this.name}:callback`)
|
||||
.pre(GuestRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.handledBy(this.handleCallback.bind(this))
|
||||
}
|
||||
|
||||
protected async handleCallback(request: Request): Promise<ResponseObject> {
|
||||
const user = await this.callback(request)
|
||||
if ( user ) {
|
||||
await this.security.authenticate(user)
|
||||
return this.redirectToIntendedRoute()
|
||||
}
|
||||
|
||||
return redirect(this.routing.getNamedPath(`@auth:${this.name}:login`).toRemote)
|
||||
}
|
||||
|
||||
/**
|
||||
* After redirecting back from the OAuth2 server, look up the user information.
|
||||
* @param request
|
||||
* @protected
|
||||
*/
|
||||
protected abstract callback(request: Request): Awaitable<Authenticatable>
|
||||
|
||||
public login(): ResponseObject {
|
||||
const buttonUrl = this.routing
|
||||
.getNamedPath(`@auth:${this.name}:redirect`)
|
||||
.toRemote
|
||||
|
||||
return view('@extollo:auth:message', {
|
||||
message: this.config.loginMessage ?? `Sign-in with ${this.config.displayName} to continue`,
|
||||
buttonText: 'Sign-in',
|
||||
buttonUrl,
|
||||
})
|
||||
}
|
||||
|
||||
public async logout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
|
||||
if ( this.config.logoutUrl ) {
|
||||
return redirect(this.config.logoutUrl)
|
||||
}
|
||||
|
||||
return view('@extollo:auth:message', {
|
||||
message: 'You have been signed-out',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL where the user should be redirected to sign-in.
|
||||
* @protected
|
||||
*/
|
||||
protected getLoginUrl(): string {
|
||||
const callbackRoute = this.routing.getNamedPath(`@auth:${this.name}:callback`)
|
||||
|
||||
return this.config.loginUrl
|
||||
.replace(/%c/g, String(this.config.clientId))
|
||||
.replace(/%r/g, callbackRoute.toRemote)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import {Field, FieldType, Model} from '../../orm'
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../types'
|
||||
import {Injectable} from '../../di'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Field, FieldType, Model} from '../../../orm'
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||
import {Injectable} from '../../../di'
|
||||
import {Awaitable, JSONState} from '../../../util'
|
||||
|
||||
/**
|
||||
* A basic ORM-driven user class.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
export class ORMUser extends Model implements Authenticatable {
|
||||
|
||||
protected static table = 'users'
|
||||
|
||||
@@ -35,8 +35,17 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
public passwordHash!: string
|
||||
|
||||
/** Human-readable display name of the user. */
|
||||
getDisplayIdentifier(): string {
|
||||
return `${this.firstName} ${this.lastName}`
|
||||
getDisplay(): string {
|
||||
if ( this.firstName || this.lastName ) {
|
||||
return `${this.firstName} ${this.lastName}`
|
||||
}
|
||||
|
||||
return this.username
|
||||
}
|
||||
|
||||
/** Globally-unique identifier of the user. */
|
||||
getUniqueIdentifier(): AuthenticatableIdentifier {
|
||||
return `user-${this.userId}`
|
||||
}
|
||||
|
||||
/** Unique identifier of the user. */
|
||||
@@ -54,6 +63,10 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
this.passwordHash = await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
validateCredential(credential: string): Awaitable<boolean> {
|
||||
return this.verifyPassword(credential)
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return this.toQueryRow()
|
||||
}
|
||||
51
src/auth/repository/orm/ORMUserRepository.ts
Normal file
51
src/auth/repository/orm/ORMUserRepository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../../types'
|
||||
import {Awaitable, Maybe, uuid4} from '../../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Container, Inject, Injectable} from '../../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query()
|
||||
.where('username', '=', id)
|
||||
.first()
|
||||
}
|
||||
|
||||
/** Returns true if this repository supports registering users. */
|
||||
supportsRegistration(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Create a user in this repository from basic credentials. */
|
||||
async createFromCredentials(username: string, password: string): Promise<Authenticatable> {
|
||||
if ( await this.getByIdentifier(username) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
username,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.makeByStaticOverride(ORMUser)
|
||||
user.username = username
|
||||
await user.setPassword(password)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/** Create a user in this repository from an external Authenticatable instance. */
|
||||
async createFromExternal(user: Authenticatable): Promise<Authenticatable> {
|
||||
return this.createFromCredentials(String(user.getUniqueIdentifier()), uuid4())
|
||||
}
|
||||
}
|
||||
54
src/auth/serial/AuthenticationEventSerializer.ts
Normal file
54
src/auth/serial/AuthenticationEventSerializer.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {BaseSerializer, ObjectSerializer, SerialPayload} from '../../support/bus'
|
||||
import {AuthenticationEvent} from '../event/AuthenticationEvent'
|
||||
import {ErrorWithContext, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
import {StaticInstantiable} from '../../di'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
||||
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
||||
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
||||
|
||||
export interface AuthenticationEventSerialPayload extends JSONState {
|
||||
user: SerialPayload<Authenticatable, JSONState>
|
||||
eventName: string
|
||||
}
|
||||
|
||||
@ObjectSerializer()
|
||||
export class AuthenticationEventSerializer extends BaseSerializer<AuthenticationEvent, AuthenticationEventSerialPayload> {
|
||||
protected async decodeSerial(serial: AuthenticationEventSerialPayload): Promise<AuthenticationEvent> {
|
||||
const user = await this.getSerialization().decode(serial.user)
|
||||
const context = await this.getRequest().make(SecurityContext)
|
||||
|
||||
const EventClass = this.getEventClass(serial.eventName)
|
||||
return new EventClass(user, context)
|
||||
}
|
||||
|
||||
protected async encodeActual(actual: AuthenticationEvent): Promise<AuthenticationEventSerialPayload> {
|
||||
return {
|
||||
eventName: actual.eventName,
|
||||
user: await this.getSerialization().encode(actual.user),
|
||||
}
|
||||
}
|
||||
|
||||
protected getName(): string {
|
||||
return '@extollo/lib:AuthenticationEventSerializer'
|
||||
}
|
||||
|
||||
matchActual(some: AuthenticationEvent): boolean {
|
||||
return some instanceof AuthenticationEvent
|
||||
}
|
||||
|
||||
protected getEventClass(name: string): StaticInstantiable<AuthenticationEvent> {
|
||||
if ( name === '@extollo/lib:UserAuthenticatedEvent' ) {
|
||||
return UserAuthenticatedEvent
|
||||
} else if ( name === '@extollo/lib:UserAuthenticationResumedEvent' ) {
|
||||
return UserAuthenticationResumedEvent
|
||||
} else if ( name === '@extollo/lib:UserFlushedEvent' ) {
|
||||
return UserFlushedEvent
|
||||
}
|
||||
|
||||
throw new ErrorWithContext('Unable to map event name to AuthenticationEvent implementation', {
|
||||
eventName: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
158
src/auth/server/OAuth2Server.ts
Normal file
158
src/auth/server/OAuth2Server.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {
|
||||
ClientRepository,
|
||||
OAuth2Client,
|
||||
OAuth2FlowType,
|
||||
OAuth2Scope,
|
||||
RedemptionCodeRepository,
|
||||
ScopeRepository,
|
||||
} from './types'
|
||||
import {HTTPError} from '../../http/HTTPError'
|
||||
import {HTTPStatus, Maybe} from '../../util'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {SecurityContext} from '../context/SecurityContext'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Server extends Controller {
|
||||
public static routes(): void {
|
||||
Route.get('/oauth2/authorize')
|
||||
.alias('@oauth2:authorize')
|
||||
.pre(AuthRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization)
|
||||
|
||||
Route.post('/oauth2/authorize')
|
||||
.alias('@oauth2:authorize:submit')
|
||||
.pre(AuthRequiredMiddleware)
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.authorizeAndRedirect)
|
||||
|
||||
Route.post('/oauth2/redeem')
|
||||
.alias('@oauth2:authorize:redeem')
|
||||
.passingRequest()
|
||||
.calls<OAuth2Server>(OAuth2Server, x => x.redeemToken)
|
||||
}
|
||||
|
||||
async redeemToken(request: Request): Promise<ResponseObject> {
|
||||
const authParts = String(request.getHeader('Authorization')).split(':')
|
||||
if ( authParts.length !== 2 ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||
const [clientId, clientSecret] = authParts
|
||||
const client = await clientRepo.find(clientId)
|
||||
if ( !client || client.secret !== clientSecret ) {
|
||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const codeRepo = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
|
||||
const codeString = request.safe('code').string()
|
||||
const code = await codeRepo.find(codeString)
|
||||
if ( !code ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async authorizeAndRedirect(request: Request): Promise<ResponseObject> {
|
||||
// Look up the client in the client repo
|
||||
const session = <Session> request.make(Session)
|
||||
const clientId = session.safe('oauth2.authorize.clientId').string()
|
||||
const client = await this.getClient(request, clientId)
|
||||
|
||||
const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows)
|
||||
if ( flowType === OAuth2FlowType.code ) {
|
||||
return this.authorizeCodeFlow(request, client)
|
||||
}
|
||||
}
|
||||
|
||||
protected async authorizeCodeFlow(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||
const session = <Session> request.make(Session)
|
||||
const security = <SecurityContext> request.make(SecurityContext)
|
||||
const codeRepository = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
|
||||
|
||||
const user = security.user()
|
||||
const scope = session.get('oauth2.authorize.scope')
|
||||
const redirectUri = session.safe('oauth2.authorize.redirectUri').in(client.allowedRedirectUris)
|
||||
|
||||
// FIXME store authorization
|
||||
|
||||
const code = await codeRepository.issue(user, client, scope)
|
||||
const uri = new URL(redirectUri)
|
||||
uri.searchParams.set('code', code.code)
|
||||
return redirect(uri)
|
||||
}
|
||||
|
||||
async promptForAuthorization(request: Request): Promise<ResponseObject> {
|
||||
// Look up the client in the client repo
|
||||
const clientId = request.safe('client_id').string()
|
||||
const client = await this.getClient(request, clientId)
|
||||
|
||||
// Make sure the requested flow type is valid for this client
|
||||
const session = <Session> request.make(Session)
|
||||
const flowType = request.safe('response_type').in(client.allowedFlows)
|
||||
const redirectUri = request.safe('redirect_uri').in(client.allowedRedirectUris)
|
||||
session.set('oauth2.authorize.clientId', client.id)
|
||||
session.set('oauth2.authorize.flow', flowType)
|
||||
session.set('oauth2.authorize.redirectUri', redirectUri)
|
||||
|
||||
// Set the state if necessary
|
||||
const state = request.input('state') || ''
|
||||
if ( state ) {
|
||||
session.set('oauth2.authorize.state', String(state))
|
||||
} else {
|
||||
session.forget('oauth2.authorize.state')
|
||||
}
|
||||
|
||||
// If the request specified a scope, validate it and set it in the session
|
||||
const scope = await this.getScope(request, client)
|
||||
|
||||
// Show a view prompting the user to approve the access
|
||||
return view('@extollo:oauth2:authorize', {
|
||||
clientName: client.display,
|
||||
scopeDescription: scope?.description,
|
||||
redirectDomain: (new URL(redirectUri)).host,
|
||||
})
|
||||
}
|
||||
|
||||
protected async getClient(request: Request, clientId: string): Promise<OAuth2Client> {
|
||||
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||
const client = await clientRepo.find(clientId)
|
||||
if ( !client ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', {
|
||||
clientId,
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
protected async getScope(request: Request, client: OAuth2Client): Promise<Maybe<OAuth2Scope>> {
|
||||
const session = <Session> request.make(Session)
|
||||
const scopeName = String(request.input('scope') || '')
|
||||
let scope: Maybe<OAuth2Scope> = undefined
|
||||
if ( scopeName ) {
|
||||
const scopeRepo = <ScopeRepository> request.make(ScopeRepository)
|
||||
scope = await scopeRepo.findByName(scopeName)
|
||||
if ( !scope || !client.allowedScopeIds.includes(scope.id) ) {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid scope', {
|
||||
scopeName,
|
||||
})
|
||||
}
|
||||
|
||||
session.set('oauth2.authorize.scope', scope.id)
|
||||
} else {
|
||||
session.forget('oauth2.authorize.state')
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
}
|
||||
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {Field, FieldType, Model} from '../../../orm'
|
||||
import {OAuth2Token} from '../types'
|
||||
|
||||
export class OAuth2TokenModel extends Model implements OAuth2Token {
|
||||
public static table = 'oauth2_tokens'
|
||||
|
||||
public static key = 'oauth2_token_id'
|
||||
|
||||
@Field(FieldType.serial, 'oauth2_token_id')
|
||||
protected oauth2TokenId!: number
|
||||
|
||||
public get id(): string {
|
||||
return String(this.oauth2TokenId)
|
||||
}
|
||||
|
||||
@Field(FieldType.varchar, 'user_id')
|
||||
public userId!: string
|
||||
|
||||
@Field(FieldType.varchar, 'client_id')
|
||||
public clientId!: string
|
||||
|
||||
@Field(FieldType.timestamp)
|
||||
public issued!: Date
|
||||
|
||||
@Field(FieldType.timestamp)
|
||||
public expires!: Date
|
||||
|
||||
@Field(FieldType.varchar)
|
||||
public scope?: string
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {isOAuth2RedemptionCode, OAuth2Client, OAuth2RedemptionCode, RedemptionCodeRepository} from '../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Cache, Maybe, uuid4} from '../../../util'
|
||||
import {Authenticatable} from '../../types'
|
||||
|
||||
@Injectable()
|
||||
export class CacheRedemptionCodeRepository extends RedemptionCodeRepository {
|
||||
@Inject()
|
||||
protected readonly cache!: Cache
|
||||
|
||||
async find(codeString: string): Promise<Maybe<OAuth2RedemptionCode>> {
|
||||
const cacheKey = `@extollo:oauth2:redemption:${codeString}`
|
||||
if ( await this.cache.has(cacheKey) ) {
|
||||
const code = await this.cache.safe(cacheKey).then(x => x.json())
|
||||
if ( isOAuth2RedemptionCode(code) ) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2RedemptionCode> {
|
||||
const code = {
|
||||
scope,
|
||||
clientId: client.id,
|
||||
userId: user.getUniqueIdentifier(),
|
||||
code: uuid4(),
|
||||
}
|
||||
|
||||
const cacheKey = `@extollo:oauth2:redemption:${code.code}`
|
||||
await this.cache.put(cacheKey, JSON.stringify(code))
|
||||
return code
|
||||
}
|
||||
}
|
||||
74
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {ClientRepository} from '../types'
|
||||
import {ConfigClientRepository} from './ConfigClientRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ClientRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ClientRepositoryFactory extends AbstractFactory<ClientRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): ClientRepository {
|
||||
return new (this.getClientRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === ClientRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getClientRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getClientRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured client repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<ClientRepository>
|
||||
*/
|
||||
protected getClientRepositoryClass(): Instantiable<ClientRepository> {
|
||||
const ClientRepositoryClass = this.config.get('oauth2.repository.client', ConfigClientRepository)
|
||||
|
||||
if ( !isInstantiable(ClientRepositoryClass) || !(ClientRepositoryClass.prototype instanceof ClientRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ClientRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ClientRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ClientRepositoryClass
|
||||
}
|
||||
}
|
||||
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal file
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {ClientRepository, OAuth2Client, isOAuth2Client} from '../types'
|
||||
import {Awaitable, ErrorWithContext, Maybe} from '../../../util'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
|
||||
@Injectable()
|
||||
export class ConfigClientRepository extends ClientRepository {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
find(id: string): Awaitable<Maybe<OAuth2Client>> {
|
||||
const client = this.config.get(`oauth2.clients.${id}`)
|
||||
if ( !isOAuth2Client(client) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 client configuration', {
|
||||
id,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
}
|
||||
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal file
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {isOAuth2Scope, OAuth2Scope, ScopeRepository} from '../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Awaitable, Maybe} from '../../../util'
|
||||
|
||||
@Injectable()
|
||||
export class ConfigScopeRepository extends ScopeRepository {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
find(id: string): Awaitable<Maybe<OAuth2Scope>> {
|
||||
const scope = this.config.get(`oauth2.scopes.${id}`)
|
||||
if ( isOAuth2Scope(scope) ) {
|
||||
return scope
|
||||
}
|
||||
}
|
||||
|
||||
findByName(name: string): Awaitable<Maybe<OAuth2Scope>> {
|
||||
return this.find(name)
|
||||
}
|
||||
}
|
||||
88
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
88
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Maybe} from '../../../util'
|
||||
import {OAuth2TokenModel} from '../models/OAuth2TokenModel'
|
||||
import {Config} from '../../../service/Config'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import {Authenticatable} from '../../types'
|
||||
|
||||
@Injectable()
|
||||
export class ORMTokenRepository extends TokenRepository {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
async find(id: string): Promise<Maybe<OAuth2Token>> {
|
||||
const idNum = parseInt(id, 10)
|
||||
if ( !isNaN(idNum) ) {
|
||||
return OAuth2TokenModel.query()
|
||||
.whereKey(idNum)
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
|
||||
const expiration = this.config.safe('outh2.token.lifetimeSeconds')
|
||||
.or(60 * 60 * 6)
|
||||
.integer() * 1000
|
||||
|
||||
const token = new OAuth2TokenModel()
|
||||
token.scope = scope
|
||||
token.userId = String(user.getUniqueIdentifier())
|
||||
token.clientId = client.id
|
||||
token.issued = new Date()
|
||||
token.expires = new Date(Math.floor(Date.now() + expiration))
|
||||
await token.save()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async encode(token: OAuth2Token): Promise<OAuth2TokenString> {
|
||||
const secret = this.config.safe('oauth2.secret').string()
|
||||
const payload = {
|
||||
id: token.id,
|
||||
userId: token.userId,
|
||||
clientId: token.clientId,
|
||||
iat: Math.floor(token.issued.valueOf() / 1000),
|
||||
exp: Math.floor(token.expires.valueOf() / 1000),
|
||||
...(token.scope ? { scope: token.scope } : {}),
|
||||
}
|
||||
|
||||
const generated = await new Promise<string>((res, rej) => {
|
||||
jwt.sign(payload, secret, {}, (err, gen) => {
|
||||
if (err || err === null || !gen) {
|
||||
rej(err || new Error('Unable to encode JWT.'))
|
||||
} else {
|
||||
res(gen)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return oauth2TokenString(generated)
|
||||
}
|
||||
|
||||
async decode(token: OAuth2TokenString): Promise<Maybe<OAuth2Token>> {
|
||||
const secret = this.config.safe('oauth2.secret').string()
|
||||
const decoded = await new Promise<any>((res, rej) => {
|
||||
jwt.verify(token, secret, {}, (err, payload) => {
|
||||
if ( err ) {
|
||||
rej(err)
|
||||
} else {
|
||||
res(payload)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const value = {
|
||||
id: decoded.id,
|
||||
userId: decoded.userId,
|
||||
clientId: decoded.clientId,
|
||||
issued: new Date(decoded.iat * 1000),
|
||||
expires: new Date(decoded.exp * 1000),
|
||||
...(decoded.scope ? { scope: decoded.scope } : {}),
|
||||
}
|
||||
|
||||
if ( isOAuth2Token(value) ) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {RedemptionCodeRepository} from '../types'
|
||||
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class RedemptionCodeRepositoryFactory extends AbstractFactory<RedemptionCodeRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): RedemptionCodeRepository {
|
||||
return new (this.getRedemptionCodeRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === RedemptionCodeRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getRedemptionCodeRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getRedemptionCodeRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured client repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<RedemptionCodeRepository>
|
||||
*/
|
||||
protected getRedemptionCodeRepositoryClass(): Instantiable<RedemptionCodeRepository> {
|
||||
const RedemptionCodeRepositoryClass = this.config.get('oauth2.repository.client', CacheRedemptionCodeRepository)
|
||||
|
||||
if ( !isInstantiable(RedemptionCodeRepositoryClass) || !(RedemptionCodeRepositoryClass.prototype instanceof RedemptionCodeRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.RedemptionCodeRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: RedemptionCodeRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return RedemptionCodeRepositoryClass
|
||||
}
|
||||
}
|
||||
74
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {ScopeRepository} from '../types'
|
||||
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract ScopeRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class ScopeRepositoryFactory extends AbstractFactory<ScopeRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): ScopeRepository {
|
||||
return new (this.getScopeRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === ScopeRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getScopeRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getScopeRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured scope repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<ScopeRepository>
|
||||
*/
|
||||
protected getScopeRepositoryClass(): Instantiable<ScopeRepository> {
|
||||
const ScopeRepositoryClass = this.config.get('oauth2.repository.scope', ConfigScopeRepository)
|
||||
|
||||
if ( !isInstantiable(ScopeRepositoryClass) || !(ScopeRepositoryClass.prototype instanceof ScopeRepository) ) {
|
||||
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ScopeRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: ScopeRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return ScopeRepositoryClass
|
||||
}
|
||||
}
|
||||
74
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
74
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
AbstractFactory,
|
||||
Container,
|
||||
DependencyRequirement,
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||
} from '../../../di'
|
||||
import {Collection, ErrorWithContext} from '../../../util'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {TokenRepository} from '../types'
|
||||
import {ORMTokenRepository} from './ORMTokenRepository'
|
||||
|
||||
/**
|
||||
* A dependency injection factory that matches the abstract TokenRepository class
|
||||
* and produces an instance of the configured repository driver implementation.
|
||||
*/
|
||||
@FactoryProducer()
|
||||
export class TokenRepositoryFactory extends AbstractFactory<TokenRepository> {
|
||||
protected get config(): Config {
|
||||
return Container.getContainer().make<Config>(Config)
|
||||
}
|
||||
|
||||
produce(): TokenRepository {
|
||||
return new (this.getTokenRepositoryClass())()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === TokenRepository
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getTokenRepositoryClass())
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.getTokenRepositoryClass()
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) {
|
||||
meta.concat(loadedMeta)
|
||||
}
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the instantiable class of the configured token repository backend.
|
||||
* @protected
|
||||
* @return Instantiable<TokenRepository>
|
||||
*/
|
||||
protected getTokenRepositoryClass(): Instantiable<TokenRepository> {
|
||||
const TokenRepositoryClass = this.config.get('oauth2.repository.token', ORMTokenRepository)
|
||||
|
||||
if ( !isInstantiable(TokenRepositoryClass) || !(TokenRepositoryClass.prototype instanceof TokenRepository) ) {
|
||||
const e = new ErrorWithContext('Provided token repository class does not extend from @extollo/lib.TokenRepository')
|
||||
e.context = {
|
||||
configKey: 'oauth2.repository.client',
|
||||
class: TokenRepositoryClass.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
return TokenRepositoryClass
|
||||
}
|
||||
}
|
||||
174
src/auth/server/types.ts
Normal file
174
src/auth/server/types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {Awaitable, hasOwnProperty, Maybe, TypeTag} from '../../util'
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../types'
|
||||
|
||||
export enum OAuth2FlowType {
|
||||
code = 'code',
|
||||
}
|
||||
|
||||
// export const oauth2FlowTypes: OAuth2FlowType[] = Object.entries(OAuth2FlowType).map(([_, value]) => value)
|
||||
|
||||
export function isOAuth2FlowType(what: unknown): what is OAuth2FlowType {
|
||||
return [OAuth2FlowType.code].includes(what as any)
|
||||
}
|
||||
|
||||
export interface OAuth2Client {
|
||||
id: string
|
||||
display: string
|
||||
secret: string
|
||||
allowedFlows: OAuth2FlowType[]
|
||||
allowedScopeIds: string[]
|
||||
allowedRedirectUris: string[]
|
||||
}
|
||||
|
||||
export function isOAuth2Client(what: unknown): what is OAuth2Client {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!hasOwnProperty(what, 'id')
|
||||
|| !hasOwnProperty(what, 'display')
|
||||
|| !hasOwnProperty(what, 'secret')
|
||||
|| !hasOwnProperty(what, 'allowedFlows')
|
||||
|| !hasOwnProperty(what, 'allowedScopeIds')
|
||||
|| !hasOwnProperty(what, 'allowedRedirectUris')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( typeof what.id !== 'string' || typeof what.display !== 'string' || typeof what.secret !== 'string' ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !Array.isArray(what.allowedScopeIds) || !what.allowedScopeIds.every(x => typeof x === 'string') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !Array.isArray(what.allowedRedirectUris) || !what.allowedRedirectUris.every(x => typeof x === 'string') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(!Array.isArray(what.allowedFlows) || !what.allowedFlows.every(x => isOAuth2FlowType(x)))
|
||||
}
|
||||
|
||||
export abstract class ClientRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Client>>
|
||||
}
|
||||
|
||||
export interface OAuth2Scope {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function isOAuth2Scope(what: unknown): what is OAuth2Scope {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( !hasOwnProperty(what, 'id') || !hasOwnProperty(what, 'name') ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( typeof what.id !== 'string' || typeof what.name !== 'string' ) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasOwnProperty(what, 'description') || typeof what.description === 'string'
|
||||
}
|
||||
|
||||
export abstract class ScopeRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Scope>>
|
||||
|
||||
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
|
||||
}
|
||||
|
||||
export interface OAuth2Token {
|
||||
id: string
|
||||
userId: AuthenticatableIdentifier
|
||||
clientId: string
|
||||
issued: Date
|
||||
expires: Date
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string
|
||||
|
||||
export function oauth2TokenString(s: string): OAuth2TokenString {
|
||||
return s as OAuth2TokenString
|
||||
}
|
||||
|
||||
export function isOAuth2Token(what: unknown): what is OAuth2Token {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!hasOwnProperty(what, 'id')
|
||||
|| !hasOwnProperty(what, 'userId')
|
||||
|| !hasOwnProperty(what, 'clientId')
|
||||
|| !hasOwnProperty(what, 'issued')
|
||||
|| !hasOwnProperty(what, 'expires')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
typeof what.id !== 'string'
|
||||
|| !(typeof what.userId === 'string' || typeof what.userId === 'number')
|
||||
|| typeof what.clientId !== 'string'
|
||||
|| !(what.issued instanceof Date)
|
||||
|| !(what.expires instanceof Date)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
|
||||
}
|
||||
|
||||
export abstract class TokenRepository {
|
||||
abstract find(id: string): Awaitable<Maybe<OAuth2Token>>
|
||||
|
||||
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
|
||||
|
||||
abstract decode(token: OAuth2TokenString): Awaitable<Maybe<OAuth2Token>>
|
||||
|
||||
abstract encode(token: OAuth2Token): Awaitable<OAuth2TokenString>
|
||||
}
|
||||
|
||||
export interface OAuth2RedemptionCode {
|
||||
clientId: string
|
||||
userId: AuthenticatableIdentifier
|
||||
code: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export function isOAuth2RedemptionCode(what: unknown): what is OAuth2RedemptionCode {
|
||||
if ( typeof what !== 'object' || what === null ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!hasOwnProperty(what, 'clientId')
|
||||
|| !hasOwnProperty(what, 'userId')
|
||||
|| !hasOwnProperty(what, 'code')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
typeof what.clientId !== 'string'
|
||||
|| !(typeof what.userId === 'number' || typeof what.userId === 'string')
|
||||
|| typeof what.code !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
|
||||
}
|
||||
|
||||
export abstract class RedemptionCodeRepository {
|
||||
abstract find(code: string): Awaitable<Maybe<OAuth2RedemptionCode>>
|
||||
|
||||
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2RedemptionCode>
|
||||
}
|
||||
@@ -3,21 +3,22 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
export interface AuthenticatableCredentials {
|
||||
identifier: string,
|
||||
credential: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
export abstract class Authenticatable implements Rehydratable {
|
||||
|
||||
/** Get the unique identifier of the user. */
|
||||
/** Get the globally-unique identifier of the user. */
|
||||
abstract getUniqueIdentifier(): AuthenticatableIdentifier
|
||||
|
||||
/** Get the repository-unique identifier of the user. */
|
||||
abstract getIdentifier(): AuthenticatableIdentifier
|
||||
|
||||
/** Get the human-readable identifier of the user. */
|
||||
abstract getDisplayIdentifier(): string
|
||||
abstract getDisplay(): string
|
||||
|
||||
/** Attempt to validate a credential of the user. */
|
||||
abstract validateCredential(credential: string): Awaitable<boolean>
|
||||
|
||||
abstract dehydrate(): Promise<JSONState>
|
||||
|
||||
@@ -28,16 +29,15 @@ export abstract class Authenticatable implements Rehydratable {
|
||||
* Base class for a repository that stores and recalls users.
|
||||
*/
|
||||
export abstract class AuthenticatableRepository {
|
||||
|
||||
/** Look up the user by their unique identifier. */
|
||||
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
/**
|
||||
* Attempt to look up and verify a user by their credentials.
|
||||
* Returns the user if the credentials are valid.
|
||||
* @param credentials
|
||||
*/
|
||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||
/** Returns true if this repository supports registering users. */
|
||||
abstract supportsRegistration(): boolean
|
||||
|
||||
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||
/** Create a user in this repository from an external Authenticatable instance. */
|
||||
abstract createFromExternal(user: Authenticatable): Awaitable<Authenticatable>
|
||||
|
||||
/** Create a user in this repository from basic credentials. */
|
||||
abstract createFromCredentials(username: string, password: string): Awaitable<Authenticatable>
|
||||
}
|
||||
|
||||
@@ -468,4 +468,22 @@ export abstract class Directive extends AppClass {
|
||||
protected nativeOutput(...outputs: any[]): void {
|
||||
console.log(...outputs) // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a promise that resolves after SIGINT is received, executing a
|
||||
* callback beforehand.
|
||||
* @param callback
|
||||
* @protected
|
||||
*/
|
||||
protected async untilInterrupt(callback?: () => unknown): Promise<void> {
|
||||
return new Promise<void>(res => {
|
||||
process.on('SIGINT', async () => {
|
||||
if ( callback ) {
|
||||
await callback()
|
||||
}
|
||||
|
||||
res()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CLIDirective = (): ClassDecorator => {
|
||||
if ( isInstantiableOf(target, Directive) ) {
|
||||
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.onResolve<CommandLine>(CommandLine, cli => {
|
||||
.onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
|
||||
cli.registerDirective(target as Instantiable<Directive>)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Directive, OptionDefinition} from '../Directive'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import Table = require('cli-table')
|
||||
import {RouteHandler} from '../../http/routing/Route'
|
||||
import {HTTPMethod} from '../../http/lifecycle/Request'
|
||||
|
||||
@Injectable()
|
||||
export class RouteDirective extends Directive {
|
||||
@@ -33,39 +33,40 @@ export class RouteDirective extends Directive {
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
|
||||
this.routing.getCompiled()
|
||||
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
|
||||
.tap(matches => {
|
||||
if ( !matches.length ) {
|
||||
this.error('No matching routes found. (Use `./ex routes` to list)')
|
||||
process.exitCode = 1
|
||||
const matched = this.routing.getCompiled()
|
||||
.filter(match => {
|
||||
if ( !method ) {
|
||||
return match.getRoute().trim() === route
|
||||
}
|
||||
|
||||
return (
|
||||
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|
||||
|| match.match(method as HTTPMethod, route)
|
||||
)
|
||||
})
|
||||
.each(match => {
|
||||
const pre = match.getMiddlewares()
|
||||
.where('stage', '=', 'pre')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
.some(match => {
|
||||
const displays = match.getDisplays()
|
||||
.map<[string, string]>(ware => [ware.stage, ware.display])
|
||||
|
||||
const post = match.getMiddlewares()
|
||||
.where('stage', '=', 'post')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
if ( displays.isEmpty() ) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
|
||||
const maxLen = displays.max(x => x[1].length)
|
||||
|
||||
const table = new Table({
|
||||
head: ['Stage', 'Handler'],
|
||||
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
|
||||
colWidths: [10, maxLen + 2],
|
||||
})
|
||||
|
||||
table.push(...pre.toArray())
|
||||
table.push(['handler', match.getDisplayableHandler()])
|
||||
table.push(...post.toArray())
|
||||
displays.each(x => table.push(x))
|
||||
|
||||
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
protected handlerToString(handler: RouteHandler): string {
|
||||
return typeof handler === 'string' ? handler : '(anonymous function)'
|
||||
if ( !matched ) {
|
||||
this.error('No matching routes found.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,21 @@ export class RoutesDirective extends Directive {
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length)
|
||||
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
|
||||
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()])
|
||||
const compiled = this.routing.getCompiled()
|
||||
|
||||
const maxRouteLength = compiled.strings().max('length')
|
||||
const maxHandlerLength = compiled.mapCall('getHandlerDisplay')
|
||||
.whereDefined()
|
||||
.max('length')
|
||||
const maxNameLength = compiled.mapCall('getAlias')
|
||||
.whereDefined()
|
||||
.max('length')
|
||||
|
||||
const rows = compiled.map(route => [String(route), route.getHandlerDisplay(), route.getAlias() || ''])
|
||||
|
||||
const table = new Table({
|
||||
head: ['Route', 'Handler'],
|
||||
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
|
||||
head: ['Route', 'Handler', 'Name'],
|
||||
colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
|
||||
})
|
||||
|
||||
table.push(...rows.toArray())
|
||||
|
||||
64
src/cli/directive/queue/ListenDirective.ts
Normal file
64
src/cli/directive/queue/ListenDirective.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Directive, OptionDefinition} from '../../Directive'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Bus, PushedToQueue, Queue} from '../../../support/bus'
|
||||
import {Queueables} from '../../../service/Queueables'
|
||||
|
||||
@Injectable()
|
||||
export class ListenDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly queue!: Queue
|
||||
|
||||
@Inject()
|
||||
protected readonly queueables!: Queueables
|
||||
|
||||
@Inject()
|
||||
protected readonly bus!: Bus
|
||||
|
||||
getDescription(): string {
|
||||
return 'listen for jobs pushed to the queue and attempt to execute them'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return 'queue-listen'
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
this.info('Subscribing to queue events...')
|
||||
await this.bus.subscribe(PushedToQueue, async () => {
|
||||
// A new job has been pushed to the queue, so try to pop it and execute it.
|
||||
// We may get undefined if some other worker is running and picked up this job first.
|
||||
await this.tryExecuteJob()
|
||||
})
|
||||
|
||||
this.info('Setting periodic poll...')
|
||||
const handle = setInterval(async () => {
|
||||
await this.tryExecuteJob()
|
||||
}, 5000)
|
||||
|
||||
this.info('Listening for jobs...')
|
||||
await this.untilInterrupt()
|
||||
|
||||
this.info('Shutting down...')
|
||||
clearInterval(handle)
|
||||
}
|
||||
|
||||
protected async tryExecuteJob(): Promise<void> {
|
||||
try {
|
||||
const job = await this.queue.pop()
|
||||
if ( !job ) {
|
||||
return // Some other worker already picked up this job
|
||||
}
|
||||
|
||||
this.info(`Executing: ${job.constructor?.name || 'unknown job'}`)
|
||||
await job.execute()
|
||||
this.success('Execution finished.')
|
||||
} catch (e: unknown) {
|
||||
this.error('Failed to execute job.')
|
||||
this.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/cli/directive/queue/WorkDirective.ts
Normal file
43
src/cli/directive/queue/WorkDirective.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {Directive, OptionDefinition} from '../../Directive'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Queue} from '../../../support/bus'
|
||||
import {Queueables} from '../../../service/Queueables'
|
||||
|
||||
@Injectable()
|
||||
export class WorkDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly queue!: Queue
|
||||
|
||||
@Inject()
|
||||
protected readonly queueables!: Queueables
|
||||
|
||||
getDescription(): string {
|
||||
return 'pop a single item from the queue and execute it'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return 'queue-work'
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
try {
|
||||
const queueable = await this.queue.pop()
|
||||
if ( !queueable ) {
|
||||
this.info('There are no items in the queue.')
|
||||
return
|
||||
}
|
||||
|
||||
this.info(`Fetched 1 item from the queue`)
|
||||
await queueable.execute()
|
||||
this.success('Executed 1 item from the queue')
|
||||
} catch (e: unknown) {
|
||||
this.error('Failed to execute queueable:')
|
||||
this.error(e)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export * from './directive/TemplateDirective'
|
||||
export * from './directive/UsageDirective'
|
||||
|
||||
export * from './decorators'
|
||||
|
||||
export * from './directive/queue/ListenDirective'
|
||||
export * from './directive/queue/WorkDirective'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Singleton, Inject} from '../../di/decorator/injection'
|
||||
import {Singleton, Inject} from '../../di'
|
||||
import {CommandLine} from './CommandLine'
|
||||
import {UsageDirective} from '../directive/UsageDirective'
|
||||
import {Directive} from '../Directive'
|
||||
@@ -9,6 +9,8 @@ import {TemplateDirective} from '../directive/TemplateDirective'
|
||||
import {RunDirective} from '../directive/RunDirective'
|
||||
import {RoutesDirective} from '../directive/RoutesDirective'
|
||||
import {RouteDirective} from '../directive/RouteDirective'
|
||||
import {WorkDirective} from '../directive/queue/WorkDirective'
|
||||
import {ListenDirective} from '../directive/queue/ListenDirective'
|
||||
|
||||
/**
|
||||
* Unit that takes the place of the final unit in the application that handles
|
||||
@@ -46,6 +48,8 @@ export class CommandLineApplication extends Unit {
|
||||
this.cli.registerDirective(RunDirective)
|
||||
this.cli.registerDirective(RoutesDirective)
|
||||
this.cli.registerDirective(RouteDirective)
|
||||
this.cli.registerDirective(WorkDirective)
|
||||
this.cli.registerDirective(ListenDirective)
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const match = this.cli.getDirectives()
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import {
|
||||
DependencyKey,
|
||||
InstanceRef,
|
||||
Instantiable,
|
||||
isInstantiable,
|
||||
StaticClass,
|
||||
StaticInstantiable,
|
||||
TypedDependencyKey,
|
||||
} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||
import {Awaitable, collect, Collection, ErrorWithContext, globalRegistry, hasOwnProperty, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
@@ -13,10 +21,57 @@ export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
|
||||
|
||||
/**
|
||||
* Singletons that implement this interface receive callbacks for
|
||||
* structural container events.
|
||||
*/
|
||||
export interface AwareOfContainerLifecycle {
|
||||
awareOfContainerLifecycle: true
|
||||
|
||||
/** Called when this key is realized by its parent container. */
|
||||
onContainerRealize?(): Awaitable<unknown>
|
||||
|
||||
/** Called before the parent container of this instance is destroyed. */
|
||||
onContainerDestroy?(): Awaitable<unknown>
|
||||
|
||||
/** Called before an instance of a key is released from the container. */
|
||||
onContainerRelease?(): Awaitable<unknown>
|
||||
}
|
||||
|
||||
export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle {
|
||||
return Boolean(
|
||||
typeof what === 'object'
|
||||
&& what !== null
|
||||
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
|
||||
&& what.awareOfContainerLifecycle,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||
*/
|
||||
export class Container {
|
||||
/**
|
||||
* Set to true when we're realizing a container.
|
||||
* Used to prevent infinite recursion when `getContainer()` is accidentally called
|
||||
* from somewhere within the `realizeContainer()` call.
|
||||
*/
|
||||
protected static realizingContainer = false
|
||||
|
||||
/**
|
||||
* List of dependency keys currently being `make`'d as a reverse stack.
|
||||
* This is used to detect dependency cycles.
|
||||
* @protected
|
||||
*/
|
||||
protected static makeStack?: Collection<DependencyKey>
|
||||
|
||||
/**
|
||||
* The 100 most recent dependency keys that were `make`'d. Used to help with
|
||||
* debugging cyclic dependency errors.
|
||||
* @protected
|
||||
*/
|
||||
protected static makeHistory?: Collection<DependencyKey>
|
||||
|
||||
/**
|
||||
* Given a Container instance, apply the ContainerBlueprint to it.
|
||||
* @param container
|
||||
@@ -46,8 +101,14 @@ export class Container {
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
if ( this.realizingContainer ) {
|
||||
throw new ErrorWithContext('Attempted getContainer call during container realization.')
|
||||
}
|
||||
|
||||
this.realizingContainer = true
|
||||
const container = Container.realizeContainer(new Container())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
this.realizingContainer = false
|
||||
return container
|
||||
}
|
||||
|
||||
@@ -66,12 +127,24 @@ export class Container {
|
||||
*/
|
||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
||||
|
||||
/**
|
||||
* Collection of static-class overrides registered with this container.
|
||||
* @protected
|
||||
*/
|
||||
protected staticOverrides: Collection<{ base: StaticInstantiable<any>, override: StaticInstantiable<any> }> = new Collection<{base: StaticInstantiable<any>; override: StaticInstantiable<any>}>()
|
||||
|
||||
/**
|
||||
* Collection of callbacks waiting for a dependency key to be resolved.
|
||||
* @protected
|
||||
*/
|
||||
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
|
||||
|
||||
/**
|
||||
* Collection of created objects that should have lifecycle events called on them, if they still exist.
|
||||
* @protected
|
||||
*/
|
||||
protected waitingLifecycleCallbacks: Collection<WeakRef<AwareOfContainerLifecycle>> = new Collection()
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
@@ -92,7 +165,14 @@ export class Container {
|
||||
* @param key
|
||||
*/
|
||||
release(key: DependencyKey): this {
|
||||
this.instances = this.instances.filter(x => x.key !== key)
|
||||
this.instances = this.instances.filter(x => {
|
||||
if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) {
|
||||
x.value.onContainerRelease?.()
|
||||
}
|
||||
|
||||
return x.key !== key
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -110,6 +190,52 @@ export class Container {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a static class as an override of some base class.
|
||||
* @param base
|
||||
* @param override
|
||||
*/
|
||||
registerStaticOverride<T>(base: StaticInstantiable<T>, override: StaticInstantiable<T>): this {
|
||||
if ( this.hasStaticOverride(base) ) {
|
||||
throw new DuplicateFactoryKeyError(base)
|
||||
}
|
||||
|
||||
this.staticOverrides.push({
|
||||
base,
|
||||
override,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/** Returns true if a static override exists for the given base class. */
|
||||
hasStaticOverride<T>(base: StaticInstantiable<T>): boolean {
|
||||
return this.staticOverrides.where('base', '=', base).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the static class overriding the base class.
|
||||
* @param base
|
||||
*/
|
||||
getStaticOverride<T>(base: StaticInstantiable<T>): StaticInstantiable<T> {
|
||||
const override = this.staticOverrides.firstWhere('base', '=', base)
|
||||
if ( override ) {
|
||||
return override.override
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registered instance of the static override of a given class.
|
||||
* @param base
|
||||
* @param parameters
|
||||
*/
|
||||
makeByStaticOverride<T>(base: StaticInstantiable<T>, ...parameters: any[]): T {
|
||||
const key = this.getStaticOverride(base)
|
||||
return this.make(key, ...parameters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given function as a factory within the container.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
@@ -244,7 +370,7 @@ export class Container {
|
||||
if ( factory ) {
|
||||
return factory
|
||||
} else {
|
||||
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
|
||||
logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +404,10 @@ export class Container {
|
||||
value: newInstance,
|
||||
})
|
||||
|
||||
if ( isAwareOfContainerLifecycle(newInstance) ) {
|
||||
newInstance.onContainerRealize?.()
|
||||
}
|
||||
|
||||
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||
if ( waiter.key === key ) {
|
||||
waiter.callback(newInstance)
|
||||
@@ -297,6 +427,8 @@ export class Container {
|
||||
* @param {array} parameters
|
||||
*/
|
||||
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
|
||||
logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack)
|
||||
|
||||
// Create the dependencies for the factory
|
||||
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
||||
const dependencies = keys.map<ResolvedDependency>(req => {
|
||||
@@ -324,12 +456,18 @@ export class Container {
|
||||
// Produce a new instance
|
||||
const inst = factory.produce(constructorArguments, params.reverse().all())
|
||||
|
||||
logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory)
|
||||
factory.getInjectedProperties().each(dependency => {
|
||||
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
|
||||
if ( dependency.key && inst ) {
|
||||
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||
}
|
||||
})
|
||||
|
||||
if ( isAwareOfContainerLifecycle(inst) ) {
|
||||
this.waitingLifecycleCallbacks.push(new WeakRef<AwareOfContainerLifecycle>(inst))
|
||||
}
|
||||
|
||||
return inst
|
||||
}
|
||||
|
||||
@@ -343,13 +481,101 @@ export class Container {
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
make<T>(target: DependencyKey, ...parameters: any[]): T {
|
||||
if ( this.hasKey(target) ) {
|
||||
return this.resolveAndCreate(target, ...parameters)
|
||||
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
|
||||
return this.produceFactory(new Factory(target), parameters)
|
||||
} else {
|
||||
throw new TypeError(`Invalid or unknown make target: ${target}`)
|
||||
if ( !Container.makeStack ) {
|
||||
Container.makeStack = new Collection()
|
||||
}
|
||||
|
||||
if ( !Container.makeHistory ) {
|
||||
Container.makeHistory = new Collection()
|
||||
}
|
||||
|
||||
Container.makeStack.push(target)
|
||||
|
||||
if ( Container.makeHistory.length > 100 ) {
|
||||
Container.makeHistory = Container.makeHistory.slice(1, 100)
|
||||
}
|
||||
Container.makeHistory.push(target)
|
||||
|
||||
this.checkForMakeCycles()
|
||||
|
||||
try {
|
||||
if (this.hasKey(target)) {
|
||||
const realized = this.resolveAndCreate(target, ...parameters)
|
||||
Container.makeStack.pop()
|
||||
return realized
|
||||
} else if (typeof target !== 'string' && isInstantiable(target)) {
|
||||
const realized = this.produceFactory(new Factory(target), parameters)
|
||||
Container.makeStack.pop()
|
||||
return realized
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
Container.makeStack.pop()
|
||||
throw e
|
||||
}
|
||||
|
||||
Container.makeStack.pop()
|
||||
throw new TypeError(`Invalid or unknown make target: ${target}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the `makeStack` for duplicates and throw an error if a dependency cycle is
|
||||
* detected. This is used to prevent infinite mutual recursion when cyclic dependencies
|
||||
* occur.
|
||||
* @protected
|
||||
*/
|
||||
protected checkForMakeCycles(): void {
|
||||
if ( !Container.makeStack ) {
|
||||
Container.makeStack = new Collection()
|
||||
}
|
||||
|
||||
if ( !Container.makeHistory ) {
|
||||
Container.makeHistory = new Collection()
|
||||
}
|
||||
|
||||
if ( Container.makeStack.unique().length !== Container.makeStack.length ) {
|
||||
const displayKey = (key: DependencyKey) => {
|
||||
if ( typeof key === 'string' ) {
|
||||
return 'key: `' + key + '`'
|
||||
} else {
|
||||
return `key: ${key.name}`
|
||||
}
|
||||
}
|
||||
|
||||
const makeStack = Container.makeStack
|
||||
.reverse()
|
||||
.map(displayKey)
|
||||
|
||||
const makeHistory = Container.makeHistory
|
||||
.reverse()
|
||||
.map(displayKey)
|
||||
|
||||
console.error('Make Stack:') // eslint-disable-line no-console
|
||||
console.error(makeStack.join('\n')) // eslint-disable-line no-console
|
||||
console.error('Make History:') // eslint-disable-line no-console
|
||||
console.error(makeHistory.join('\n')) // eslint-disable-line no-console
|
||||
throw new ErrorWithContext('Cyclic dependency chain detected', {
|
||||
makeStack,
|
||||
makeHistory,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance of the dependency key using this container, ignoring any pre-existing instances
|
||||
* in this container.
|
||||
* @param key
|
||||
* @param parameters
|
||||
*/
|
||||
makeNew<T>(key: TypedDependencyKey<T>, ...parameters: any[]): T {
|
||||
if ( isInstantiable(key) ) {
|
||||
const result = this.produceFactory(new Factory(key), parameters)
|
||||
if ( isAwareOfContainerLifecycle(result) ) {
|
||||
result.onContainerRealize?.()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid or unknown make target: ${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,6 +592,21 @@ export class Container {
|
||||
return factory.getDependencyKeys().pluck('key')
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform any cleanup necessary to destroy this container instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.waitingLifecycleCallbacks
|
||||
.mapCall('deref')
|
||||
.whereDefined()
|
||||
.each(inst => {
|
||||
if ( isAwareOfContainerLifecycle(inst) ) {
|
||||
inst.onContainerRelease?.()
|
||||
inst.onContainerDestroy?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a different container, copy the factories and instances from this container over to it.
|
||||
* @param container
|
||||
|
||||
11
src/di/constructable.ts
Normal file
11
src/di/constructable.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,7 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
*/
|
||||
export const FactoryProducer = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
logIfDebugging('extollo.di.injector', 'Registering factory producer for target:', target)
|
||||
if ( isInstantiable(target) ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||
}
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './types'
|
||||
|
||||
export * from './decorator/injection'
|
||||
export * from './InjectionAware'
|
||||
export * from './constructable'
|
||||
|
||||
@@ -36,7 +36,18 @@ export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): w
|
||||
/**
|
||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||
*/
|
||||
export type StaticClass<T, T2> = Function & {prototype: T} & T2 // eslint-disable-line @typescript-eslint/ban-types
|
||||
export type StaticClass<T, T2, TCtorParams extends any[] = any[]> = T2 & StaticThis<T, TCtorParams> // eslint-disable-line @typescript-eslint/ban-types
|
||||
|
||||
/**
|
||||
* Quasi-reference to a `this` type w/in a static member.
|
||||
* @see https://github.com/microsoft/TypeScript/issues/5863#issuecomment-302861175
|
||||
*/
|
||||
export type StaticThis<T, TCtorParams extends any[]> = { new (...args: TCtorParams): T }
|
||||
|
||||
/**
|
||||
* Type that identifies a value as a static class that instantiates to itself
|
||||
*/
|
||||
export type StaticInstantiable<T> = StaticClass<T, Instantiable<T>>
|
||||
|
||||
/**
|
||||
* Returns true if the parameter is a static class.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {Dispatchable} from './types'
|
||||
import {Awaitable, JSONState} from '../util'
|
||||
|
||||
/**
|
||||
* Abstract class representing an event that may be fired.
|
||||
*/
|
||||
export abstract class Event implements Dispatchable {
|
||||
|
||||
|
||||
abstract dehydrate(): Awaitable<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {Instantiable, Singleton, StaticClass} from '../di'
|
||||
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
|
||||
import {Awaitable, Collection, uuid4} from '../util'
|
||||
|
||||
/**
|
||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
||||
*/
|
||||
@Singleton()
|
||||
export class EventBus implements Bus {
|
||||
/**
|
||||
* Collection of subscribers, by their events.
|
||||
* @protected
|
||||
*/
|
||||
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
||||
|
||||
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
||||
const entry: EventSubscriberEntry<T> = {
|
||||
id: uuid4(),
|
||||
event,
|
||||
subscriber,
|
||||
}
|
||||
|
||||
this.subscribers.push(entry)
|
||||
return this.buildSubscription(entry.id)
|
||||
}
|
||||
|
||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
|
||||
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
|
||||
}
|
||||
|
||||
async dispatch(event: Dispatchable): Promise<void> {
|
||||
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
||||
await this.subscribers.where('event', '=', eventClass)
|
||||
.promiseMap(entry => entry.subscriber(event))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an EventSubscription object for the subscriber of the given ID.
|
||||
* @param id
|
||||
* @protected
|
||||
*/
|
||||
protected buildSubscription(id: string): EventSubscription {
|
||||
let subscribed = true
|
||||
return {
|
||||
unsubscribe: (): Awaitable<void> => {
|
||||
if ( subscribed ) {
|
||||
this.subscribers = this.subscribers.where('id', '!=', id)
|
||||
subscribed = false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import {EventBus} from './EventBus'
|
||||
import {Collection} from '../util'
|
||||
import {Bus, Dispatchable} from './types'
|
||||
|
||||
/**
|
||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
||||
* This bus also supports "propagating" events along to any other connected buses.
|
||||
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
|
||||
* level bus whose events still reach the global EventBus instance.
|
||||
*/
|
||||
export class PropagatingEventBus extends EventBus {
|
||||
protected recipients: Collection<Bus> = new Collection<Bus>()
|
||||
|
||||
async dispatch(event: Dispatchable): Promise<void> {
|
||||
await super.dispatch(event)
|
||||
await this.recipients.promiseMap(bus => bus.dispatch(event))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given bus to receive events fired on this bus.
|
||||
* @param recipient
|
||||
*/
|
||||
connect(recipient: Bus): void {
|
||||
if ( !this.recipients.includes(recipient) ) {
|
||||
this.recipients.push(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import {Awaitable, Rehydratable} from '../util'
|
||||
import {Instantiable, StaticClass} from '../di'
|
||||
|
||||
/**
|
||||
* A closure that should be executed with the given event is fired.
|
||||
*/
|
||||
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
|
||||
|
||||
/**
|
||||
* An object used to track event subscriptions internally.
|
||||
*/
|
||||
export interface EventSubscriberEntry<T extends Dispatchable> {
|
||||
/** Globally unique ID of this subscription. */
|
||||
id: string
|
||||
|
||||
/** The event class subscribed to. */
|
||||
event: StaticClass<T, Instantiable<T>>
|
||||
|
||||
/** The closure to execute when the event is fired. */
|
||||
subscriber: EventSubscriber<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* An object returned upon subscription, used to unsubscribe.
|
||||
*/
|
||||
export interface EventSubscription {
|
||||
/**
|
||||
* Unsubscribe the associated listener from the event bus.
|
||||
*/
|
||||
unsubscribe(): Awaitable<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance of something that can be fired on an event bus.
|
||||
*/
|
||||
export interface Dispatchable extends Rehydratable {
|
||||
shouldQueue?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-driven bus that manages subscribers and dispatched items.
|
||||
*/
|
||||
export interface Bus {
|
||||
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
||||
dispatch(event: Dispatchable): Awaitable<void>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Arr } from './arrays'
|
||||
export { Cast } from './inference'
|
||||
export { Num } from './numeric'
|
||||
export { Is } from './presence'
|
||||
export { Str } from './strings'
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Request} from './lifecycle/Request'
|
||||
import {Container} from '../di'
|
||||
import {Container, Inject} from '../di'
|
||||
import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
@@ -7,11 +7,8 @@ import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||
* handle HTTP requests.
|
||||
*/
|
||||
export class Controller extends CanonicalItemClass {
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
protected container(): Container {
|
||||
return this.request
|
||||
|
||||
25
src/http/RequestClass.ts
Normal file
25
src/http/RequestClass.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
import {Container, Inject, Injectable} from '../di'
|
||||
import {RequestLocalStorage} from './RequestLocalStorage'
|
||||
import {Request} from './lifecycle/Request'
|
||||
|
||||
/**
|
||||
* Base for classes that gives access to the global application and request container.
|
||||
*
|
||||
* Similar to AppClass, but provides the Request instead of the Container.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestClass extends AppClass {
|
||||
@Inject()
|
||||
protected readonly requestClassStorage!: RequestLocalStorage
|
||||
|
||||
/** Get the request container. **/
|
||||
protected container(): Container {
|
||||
return this.requestClassStorage.get()
|
||||
}
|
||||
|
||||
/** Get the request. */
|
||||
protected request(): Request {
|
||||
return this.requestClassStorage.get()
|
||||
}
|
||||
}
|
||||
32
src/http/RequestLocalStorage.ts
Normal file
32
src/http/RequestLocalStorage.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
import {Request} from './lifecycle/Request'
|
||||
import {Singleton} from '../di'
|
||||
import {ErrorWithContext} from '../util'
|
||||
|
||||
export class InvalidOutOfRequestAccessError extends ErrorWithContext {
|
||||
constructor() {
|
||||
super(`Attempted to access request via local storage outside of async lifecycle!`)
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton()
|
||||
export class RequestLocalStorage {
|
||||
protected readonly store: AsyncLocalStorage<Request> = new AsyncLocalStorage<Request>()
|
||||
|
||||
get(): Request {
|
||||
const req = this.store.getStore()
|
||||
if ( !req ) {
|
||||
throw new InvalidOutOfRequestAccessError()
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
has(): boolean {
|
||||
return Boolean(this.store.getStore())
|
||||
}
|
||||
|
||||
run<T>(req: Request, closure: () => T): T {
|
||||
return this.store.run(req, closure)
|
||||
}
|
||||
}
|
||||
28
src/http/kernel/module/ClearRequestEventBusHTTPModule.ts
Normal file
28
src/http/kernel/module/ClearRequestEventBusHTTPModule.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {Bus} from '../../../support/bus'
|
||||
|
||||
/**
|
||||
* HTTP kernel module that creates a request-specific event bus
|
||||
* and injects it into the request container.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearRequestEventBusHTTPModule extends HTTPKernelModule {
|
||||
@Inject()
|
||||
protected bus!: Bus
|
||||
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).first()
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
const requestBus = request.make<Bus>(Bus)
|
||||
await requestBus.down()
|
||||
|
||||
// FIXME disconnect request bus from global event bus
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||
import {ResponseObject} from '../../routing/Route'
|
||||
import {http} from '../../response/HTTPErrorResponseFactory'
|
||||
import {HTTPStatus} from '../../../util'
|
||||
import {HTTPStatus, withErrorContext} from '../../../util'
|
||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||
|
||||
/**
|
||||
@@ -18,10 +17,21 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRoute
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
if ( request.hasInstance(ActivatedRoute) ) {
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const object: ResponseObject = await route.handler(request)
|
||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
||||
const params = route.resolvedParameters
|
||||
if ( !params ) {
|
||||
throw new Error('Attempted to call route handler without resolved parameters.')
|
||||
}
|
||||
|
||||
await this.applyResponseObject(object, request)
|
||||
await withErrorContext(async () => {
|
||||
const result = await route.handler
|
||||
.tap(handler => handler(...params))
|
||||
.apply(request)
|
||||
|
||||
await this.applyResponseObject(result, request)
|
||||
}, {
|
||||
route,
|
||||
})
|
||||
} else {
|
||||
await http(HTTPStatus.NOT_FOUND).write(request)
|
||||
request.response.blockingWriteback(true)
|
||||
|
||||
@@ -17,7 +17,7 @@ export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRo
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
if ( request.hasInstance(ActivatedRoute) ) {
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
||||
const postflight = route.postflight
|
||||
|
||||
for ( const handler of postflight ) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Request} from '../../lifecycle/Request'
|
||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
||||
import {ResponseObject} from '../../routing/Route'
|
||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
|
||||
|
||||
/**
|
||||
* HTTP Kernel module that executes the preflight handlers for the route.
|
||||
@@ -17,16 +18,28 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
if ( request.hasInstance(ActivatedRoute) ) {
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
||||
const preflight = route.preflight
|
||||
|
||||
for ( const handler of preflight ) {
|
||||
const result: ResponseObject = await handler(request)
|
||||
if ( typeof result !== 'undefined' ) {
|
||||
await this.applyResponseObject(result, request)
|
||||
request.response.blockingWriteback(true)
|
||||
}
|
||||
await withErrorContext(async () => {
|
||||
const result: ResponseObject = await handler(request)
|
||||
if ( typeof result !== 'undefined' ) {
|
||||
await this.applyResponseObject(result, request)
|
||||
request.response.blockingWriteback(true)
|
||||
}
|
||||
}, { handler })
|
||||
}
|
||||
|
||||
const parameters = route.parameters
|
||||
const resolveResult = await collect(parameters)
|
||||
.asyncMapRight(handler => handler(request))
|
||||
|
||||
if ( isLeft(resolveResult) ) {
|
||||
return unleft(resolveResult)
|
||||
}
|
||||
|
||||
route.resolvedParameters = unright(resolveResult).toArray()
|
||||
}
|
||||
|
||||
return request
|
||||
|
||||
@@ -2,8 +2,7 @@ import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {HTTPKernel} from '../HTTPKernel'
|
||||
import {Request} from '../../lifecycle/Request'
|
||||
import {EventBus} from '../../../event/EventBus'
|
||||
import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
|
||||
import {Bus} from '../../../support/bus'
|
||||
|
||||
/**
|
||||
* HTTP kernel module that creates a request-specific event bus
|
||||
@@ -12,17 +11,18 @@ import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
|
||||
@Injectable()
|
||||
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
|
||||
@Inject()
|
||||
protected bus!: EventBus
|
||||
protected bus!: Bus
|
||||
|
||||
public static register(kernel: HTTPKernel): void {
|
||||
kernel.register(this).first()
|
||||
}
|
||||
|
||||
public async apply(request: Request): Promise<Request> {
|
||||
const bus = <PropagatingEventBus> this.make(PropagatingEventBus)
|
||||
bus.connect(this.bus)
|
||||
const requestBus = this.container().makeNew<Bus>(Bus)
|
||||
await requestBus.up(false)
|
||||
await requestBus.connect(this.bus)
|
||||
|
||||
request.purge(EventBus).registerProducer(EventBus, () => bus)
|
||||
request.purge(Bus).registerProducer(Bus, () => requestBus)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
|
||||
const route = this.routing.match(request.method, request.path)
|
||||
if ( route ) {
|
||||
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
|
||||
const activated = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
|
||||
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
|
||||
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
|
||||
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
|
||||
} else {
|
||||
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {Injectable, ScopedContainer, Container} from '../../di'
|
||||
import {infer, UniversalPath} from '../../util'
|
||||
import {Container, Injectable, ScopedContainer} from '../../di'
|
||||
import {HTTPStatus, infer, Pipeline, Safe, UniversalPath} from '../../util'
|
||||
import {IncomingMessage, ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {TLSSocket} from 'tls'
|
||||
import * as url from 'url'
|
||||
import {Response} from './Response'
|
||||
import * as Negotiator from 'negotiator'
|
||||
import {HTTPError} from '../HTTPError'
|
||||
import {ActivatedRoute} from '../routing/ActivatedRoute'
|
||||
|
||||
/**
|
||||
* Enumeration of different HTTP verbs.
|
||||
@@ -176,23 +178,41 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
* @param key
|
||||
*/
|
||||
public input(key?: string): unknown {
|
||||
if ( !key ) {
|
||||
return {
|
||||
...this.parsedInput,
|
||||
...this.query,
|
||||
...this.uploadedFiles,
|
||||
let sources = {
|
||||
...this.parsedInput,
|
||||
...this.query,
|
||||
...this.uploadedFiles,
|
||||
}
|
||||
|
||||
if ( this.hasKey(ActivatedRoute) ) {
|
||||
sources = {
|
||||
...sources,
|
||||
...this.make<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute).params,
|
||||
}
|
||||
}
|
||||
|
||||
if ( key in this.parsedInput ) {
|
||||
return this.parsedInput[key]
|
||||
if ( !key ) {
|
||||
return sources
|
||||
}
|
||||
|
||||
if ( key in this.query ) {
|
||||
return this.query[key]
|
||||
if ( key in sources ) {
|
||||
return sources[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a field from the request and wrap it in a safe-value accessor.
|
||||
* @param key
|
||||
*/
|
||||
public safe(key?: string): Safe {
|
||||
return Pipeline.id()
|
||||
.tap(val => new Safe(val))
|
||||
.tap(safe => safe.onError(message => {
|
||||
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid field (${key}): ${message}`)
|
||||
}))
|
||||
.apply(this.input(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UniversalPath instance for a file uploaded in the given field on the request.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
||||
import {ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {Readable} from 'stream'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Error thrown when the server tries to re-send headers after they have been sent once.
|
||||
@@ -68,6 +69,10 @@ export class Response {
|
||||
protected readonly serverResponse: ServerResponse,
|
||||
) { }
|
||||
|
||||
protected get logging(): Logging {
|
||||
return this.request.make(Logging)
|
||||
}
|
||||
|
||||
/** Get the currently set response status. */
|
||||
public getStatus(): HTTPStatus {
|
||||
return this.status
|
||||
@@ -95,6 +100,7 @@ export class Response {
|
||||
|
||||
/** Set the value of the response header. */
|
||||
public setHeader(name: string, value: string | string[]): this {
|
||||
this.logging.verbose(`Will set header on response: ${name}`)
|
||||
if ( this.sentHeaders ) {
|
||||
throw new HeadersAlreadySentError(this, name)
|
||||
}
|
||||
@@ -108,6 +114,7 @@ export class Response {
|
||||
* @param data
|
||||
*/
|
||||
public setHeaders(data: {[name: string]: string | string[]}): this {
|
||||
this.logging.verbose(`Will set headers on response: ${Object.keys(data).join(', ')}`)
|
||||
if ( this.sentHeaders ) {
|
||||
throw new HeadersAlreadySentError(this)
|
||||
}
|
||||
@@ -123,12 +130,16 @@ export class Response {
|
||||
* @param value
|
||||
*/
|
||||
public appendHeader(name: string, value: string | string[]): this {
|
||||
this.logging.verbose(`Will append header: ${name}`)
|
||||
|
||||
if ( this.sentHeaders ) {
|
||||
throw new HeadersAlreadySentError(this, name)
|
||||
}
|
||||
|
||||
if ( !Array.isArray(value) ) {
|
||||
value = [value]
|
||||
}
|
||||
|
||||
let existing = this.headers[name] ?? []
|
||||
if ( !Array.isArray(existing) ) {
|
||||
existing = [existing]
|
||||
@@ -147,6 +158,7 @@ export class Response {
|
||||
* Write the headers to the client.
|
||||
*/
|
||||
public sendHeaders(): this {
|
||||
this.logging.verbose(`Sending headers...`)
|
||||
const headers = {} as any
|
||||
|
||||
const setCookieHeaders = this.cookies.getSetCookieHeaders()
|
||||
@@ -194,7 +206,12 @@ export class Response {
|
||||
* @param data
|
||||
*/
|
||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||
this.logging.verbose(`Writing headers & data to response... (destroyed? ${this.serverResponse.destroyed})`)
|
||||
return new Promise<void>((res, rej) => {
|
||||
if ( this.responseEnded || this.serverResponse.destroyed ) {
|
||||
throw new ErrorWithContext('Tried to write to Response after lifecycle ended.')
|
||||
}
|
||||
|
||||
if ( !this.sentHeaders ) {
|
||||
this.sendHeaders()
|
||||
}
|
||||
@@ -226,7 +243,7 @@ export class Response {
|
||||
await this.sending$.next(this)
|
||||
|
||||
if ( !(this.body instanceof Readable) ) {
|
||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||
this.setHeader('Content-Length', String(Buffer.from(this.body ?? '').length))
|
||||
}
|
||||
|
||||
this.setHeader('Date', (new Date()).toUTCString())
|
||||
@@ -238,6 +255,14 @@ export class Response {
|
||||
await this.sent$.next(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the response can still be sent. False if it has been sent
|
||||
* or the connection has been destroyed.
|
||||
*/
|
||||
public canSend(): boolean {
|
||||
return !(this.responseEnded || this.serverResponse.destroyed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the response as ended and close the socket.
|
||||
*/
|
||||
|
||||
@@ -72,9 +72,12 @@ export class ErrorResponseFactory extends ResponseFactory {
|
||||
}
|
||||
}
|
||||
|
||||
const suggestion = this.getSuggestion()
|
||||
|
||||
let str = `
|
||||
<b>Sorry, an unexpected error occurred while processing your request.</b>
|
||||
<br>
|
||||
${suggestion ? '<br><b>Suggestion:</b> ' + suggestion + '<br>' : ''}
|
||||
<pre><code>
|
||||
Name: ${thrownError.name}
|
||||
Message: ${thrownError.message}
|
||||
@@ -88,7 +91,7 @@ Stack trace:
|
||||
str += `
|
||||
<pre><code>
|
||||
Context:
|
||||
${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
|
||||
${Object.keys(context).map(key => ` - ${key} : ${JSON.stringify(context[key]).replace(/\n/g, '<br>')}`)
|
||||
.join('\n')}
|
||||
</code></pre>
|
||||
`
|
||||
@@ -100,4 +103,12 @@ ${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
|
||||
protected buildJSON(thrownError: Error): string {
|
||||
return JSON.stringify(api.error(thrownError))
|
||||
}
|
||||
|
||||
protected getSuggestion(): string {
|
||||
if ( this.thrownError.message.startsWith('No such dependency is registered with this container: class SecurityContext') ) {
|
||||
return 'It looks like this route relies on the security framework. Is the route you are accessing inside a middleware (e.g. SessionAuthMiddleware)?'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import {Request} from '../lifecycle/Request'
|
||||
* Helper function to create a new RedirectResponseFactory to the given destination.
|
||||
* @param destination
|
||||
*/
|
||||
export function redirect(destination: string): RedirectResponseFactory {
|
||||
return new RedirectResponseFactory(destination)
|
||||
export function redirect(destination: string|URL): RedirectResponseFactory {
|
||||
return new RedirectResponseFactory(destination instanceof URL ? destination.toString() : destination)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,15 @@ export function view(name: string, data?: {[key: string]: any}): ViewResponseFac
|
||||
return new ViewResponseFactory(name, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that creates a new ViewResponseFactory that redirects the user to
|
||||
* the given URL.
|
||||
* @param url
|
||||
*/
|
||||
export function redirectToGet(url: string): ViewResponseFactory {
|
||||
return view('@extollo:redirect', { redirectUrl: url })
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response factory that uses the ViewEngine service to render a view
|
||||
* and send it as HTML.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {ResolvedRouteHandler, Route} from './Route'
|
||||
import {Injectable} from '../../di'
|
||||
import {ParameterProvidingMiddleware, ResolvedRouteHandler, Route} from './Route'
|
||||
import {Constructable, Injectable} from '../../di'
|
||||
|
||||
export type HandlerParamProviders<THandlerParams extends unknown[]> = {
|
||||
[Index in keyof THandlerParams]: ParameterProvidingMiddleware<THandlerParams[Index]>
|
||||
} & {length: THandlerParams['length']}
|
||||
|
||||
/**
|
||||
* Class representing a resolved route that a request is mounted to.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivatedRoute {
|
||||
export class ActivatedRoute<TReturn, THandlerParams extends unknown[]> {
|
||||
/**
|
||||
* The parsed params from the route definition.
|
||||
*
|
||||
@@ -27,7 +31,7 @@ export class ActivatedRoute {
|
||||
/**
|
||||
* The resolved function that should handle the request for this route.
|
||||
*/
|
||||
public readonly handler: ResolvedRouteHandler
|
||||
public readonly handler: Constructable<(...x: THandlerParams) => TReturn>
|
||||
|
||||
/**
|
||||
* Pre-middleware that should be applied to the request on this route.
|
||||
@@ -39,9 +43,13 @@ export class ActivatedRoute {
|
||||
*/
|
||||
public readonly postflight: ResolvedRouteHandler[]
|
||||
|
||||
public readonly parameters: HandlerParamProviders<THandlerParams>
|
||||
|
||||
public resolvedParameters?: THandlerParams
|
||||
|
||||
constructor(
|
||||
/** The route this ActivatedRoute refers to. */
|
||||
public readonly route: Route,
|
||||
public readonly route: Route<TReturn, THandlerParams>,
|
||||
|
||||
/** The request path that activated that route. */
|
||||
public readonly path: string,
|
||||
@@ -56,9 +64,17 @@ export class ActivatedRoute {
|
||||
throw error
|
||||
}
|
||||
|
||||
if ( !route.handler ) {
|
||||
throw new ErrorWithContext('Cannot instantiate ActivatedRoute. Matched route is not handled.', {
|
||||
matchedRoute: String(route),
|
||||
requestPath: path,
|
||||
})
|
||||
}
|
||||
|
||||
this.params = params
|
||||
this.preflight = route.resolvePreflight()
|
||||
this.handler = route.resolveHandler()
|
||||
this.postflight = route.resolvePostflight()
|
||||
this.preflight = route.getPreflight().toArray()
|
||||
this.handler = route.handler
|
||||
this.postflight = route.getPostflight().toArray()
|
||||
this.parameters = route.getParameters().toArray() as HandlerParamProviders<THandlerParams>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export abstract class Middleware extends CanonicalItemClass {
|
||||
protected readonly request: Request,
|
||||
) {
|
||||
super()
|
||||
if ( !request ) {
|
||||
throw new Error('Middleware constructed without request')
|
||||
}
|
||||
}
|
||||
|
||||
protected container(): Container {
|
||||
|
||||
@@ -1,55 +1,40 @@
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {HTTPMethod, Request} from '../lifecycle/Request'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
import {RouteGroup} from './RouteGroup'
|
||||
import {Collection, Either, ErrorWithContext, Maybe, Pipeline, PrefixTypeArray, right} from '../../util'
|
||||
import {ResponseFactory} from '../response/ResponseFactory'
|
||||
import {Response} from '../lifecycle/Response'
|
||||
import {Controllers} from '../../service/Controllers'
|
||||
import {ErrorWithContext, Collection} from '../../util'
|
||||
import {Container} from '../../di'
|
||||
import {Controller} from '../Controller'
|
||||
import {Middlewares} from '../../service/Middlewares'
|
||||
import {HTTPMethod, Request} from '../lifecycle/Request'
|
||||
import {constructable, Constructable, Container, Instantiable, isInstantiableOf, TypedDependencyKey} from '../../di'
|
||||
import {Middleware} from './Middleware'
|
||||
import {Valid, Validator, ValidatorFactory} from '../../validation/Validator'
|
||||
import {validateMiddleware} from '../../validation/middleware'
|
||||
import {RouteGroup} from './RouteGroup'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
|
||||
/**
|
||||
* 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 an item that defines a direct route handler.
|
||||
*/
|
||||
export type RouteHandler = ((request: Request, response: Response) => ResponseObject) | ((request: Request) => ResponseObject) | (() => ResponseObject) | string
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
// TODO domains, named routes - support this on groups as well
|
||||
export interface HandledRoute<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
|
||||
handler: Constructable<(...x: THandlerParams) => TReturn>
|
||||
|
||||
/**
|
||||
* A class that can be used to build and reference dynamic routes in the application.
|
||||
*
|
||||
* Routes can be defined in nested groups, with prefixes and middleware handlers.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Route.post('/api/v1/ping', (request: Request) => {
|
||||
* return 'pong!'
|
||||
* })
|
||||
*
|
||||
* Route.group('/api/v2', () => {
|
||||
* Route.get('/status', 'controller::api:v2:Status.getStatus').pre('auth:UserOnly')
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class Route extends AppClass {
|
||||
/**
|
||||
* Set a programmatic name for this route.
|
||||
* @param name
|
||||
*/
|
||||
alias(name: string): this
|
||||
}
|
||||
|
||||
export class Route<TReturn extends ResponseObject, THandlerParams extends unknown[] = []> {
|
||||
/** Routes that have been created and registered in the application. */
|
||||
private static registeredRoutes: Route[] = []
|
||||
private static registeredRoutes: Route<unknown, unknown[]>[] = []
|
||||
|
||||
/** Groups of routes that have been registered with the application. */
|
||||
private static registeredGroups: RouteGroup[] = []
|
||||
@@ -66,13 +51,13 @@ 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
|
||||
* them and also expose any handler resolution errors that might happen at runtime.
|
||||
*/
|
||||
public static async compile(): Promise<Route[]> {
|
||||
public static async compile(): Promise<Route<unknown, unknown[]>[]> {
|
||||
let registeredRoutes = this.registeredRoutes
|
||||
const registeredGroups = this.registeredGroups
|
||||
|
||||
@@ -86,55 +71,45 @@ export class Route extends AppClass {
|
||||
for ( const route of registeredRoutes ) {
|
||||
for ( const group of stack ) {
|
||||
route.prepend(group.prefix)
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.where('stage', '=', 'pre')
|
||||
.each(def => {
|
||||
route.prependMiddleware(def)
|
||||
})
|
||||
group.getPreflight()
|
||||
.each(def => route.preflight.prepend(
|
||||
request => request.make<Middleware>(def, request).apply(),
|
||||
))
|
||||
}
|
||||
|
||||
for ( const group of this.compiledGroupStack ) {
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.where('stage', '=', 'post')
|
||||
.each(def => route.appendMiddleware(def))
|
||||
group.getPostflight()
|
||||
.each(def => route.postflight.push(
|
||||
request => request.make<Middleware>(def, request).apply(),
|
||||
))
|
||||
}
|
||||
|
||||
// Add the global pre- and post- middleware
|
||||
if ( Array.isArray(globalMiddleware?.pre) ) {
|
||||
const globalPre = [...globalMiddleware.pre].reverse()
|
||||
for ( const item of globalPre ) {
|
||||
if ( typeof item !== 'string' ) {
|
||||
throw new ErrorWithContext(`Invalid global pre-middleware definition. Global middleware must be string-references.`, {
|
||||
if ( !isInstantiableOf(item, Middleware) ) {
|
||||
throw new ErrorWithContext(`Invalid global pre-middleware definition. Global middleware must be static references to Middleware implementations.`, {
|
||||
configKey: 'server.middleware.global.pre',
|
||||
})
|
||||
}
|
||||
|
||||
route.prependMiddleware({
|
||||
stage: 'pre',
|
||||
handler: item,
|
||||
})
|
||||
route.preflight.prepend(request => request.make<Middleware>(item, request).apply())
|
||||
}
|
||||
}
|
||||
|
||||
if ( Array.isArray(globalMiddleware?.post) ) {
|
||||
const globalPost = [...globalMiddleware.post]
|
||||
for ( const item of globalPost ) {
|
||||
if ( typeof item !== 'string' ) {
|
||||
throw new ErrorWithContext(`Invalid global post-middleware definition. Global middleware must be string-references.`, {
|
||||
if ( !isInstantiableOf(item, Middleware) ) {
|
||||
throw new ErrorWithContext(`Invalid global post-middleware definition. Global middleware must be static references to Middleware implementations.`, {
|
||||
configKey: 'server.middleware.global.post',
|
||||
})
|
||||
}
|
||||
|
||||
route.appendMiddleware({
|
||||
stage: 'post',
|
||||
handler: item,
|
||||
})
|
||||
route.postflight.push(request => request.make<Middleware>(item, request).apply())
|
||||
}
|
||||
}
|
||||
|
||||
route.resolvePreflight() // Try to resolve here to catch any errors at boot-time and pre-compile
|
||||
route.resolveHandler()
|
||||
route.resolvePostflight()
|
||||
}
|
||||
|
||||
for ( const group of registeredGroups ) {
|
||||
@@ -150,50 +125,46 @@ export class Route extends AppClass {
|
||||
return registeredRoutes
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new route on the given endpoint for the given HTTP verb.
|
||||
* @param method
|
||||
* @param definition
|
||||
* @param handler
|
||||
* @param endpoint
|
||||
*/
|
||||
public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler): Route {
|
||||
const route = new Route(method, handler, definition)
|
||||
this.registeredRoutes.push(route)
|
||||
return route
|
||||
public static endpoint(method: HTTPMethod | HTTPMethod[], endpoint: string): Route<ResponseObject> {
|
||||
return new Route(method, endpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new GET route on the given endpoint.
|
||||
* @param definition
|
||||
* @param handler
|
||||
*/
|
||||
public static get(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint('get', definition, handler)
|
||||
public static get(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint('get', endpoint)
|
||||
}
|
||||
|
||||
/** Create a new POST route on the given endpoint. */
|
||||
public static post(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint('post', definition, handler)
|
||||
public static post(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint('post', endpoint)
|
||||
}
|
||||
|
||||
/** Create a new PUT route on the given endpoint. */
|
||||
public static put(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint('put', definition, handler)
|
||||
public static put(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint('put', endpoint)
|
||||
}
|
||||
|
||||
/** Create a new PATCH route on the given endpoint. */
|
||||
public static patch(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint('patch', definition, handler)
|
||||
public static patch(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint('patch', endpoint)
|
||||
}
|
||||
|
||||
/** Create a new DELETE route on the given endpoint. */
|
||||
public static delete(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint('delete', definition, handler)
|
||||
public static delete(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint('delete', endpoint)
|
||||
}
|
||||
|
||||
/** Create a new route on all HTTP verbs, on the given endpoint. */
|
||||
public static any(definition: string, handler: RouteHandler): Route {
|
||||
return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler)
|
||||
public static any(endpoint: string): Route<ResponseObject> {
|
||||
return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], endpoint)
|
||||
}
|
||||
|
||||
/** Create a new route group with the given prefix. */
|
||||
@@ -203,33 +174,22 @@ export class Route extends AppClass {
|
||||
return grp
|
||||
}
|
||||
|
||||
/** Middlewares that should be applied to this route. */
|
||||
protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: 'pre' | 'post'; handler: RouteHandler}>()
|
||||
protected preflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
|
||||
|
||||
/** Pre-compiled route handlers for the pre-middleware for this route. */
|
||||
protected compiledPreflight?: ResolvedRouteHandler[]
|
||||
protected parameters: Collection<ParameterProvidingMiddleware<unknown>> = new Collection<ParameterProvidingMiddleware<unknown>>()
|
||||
|
||||
/** Pre-compiled route handlers for the post-middleware for this route. */
|
||||
protected compiledHandler?: ResolvedRouteHandler
|
||||
protected postflight: Collection<ResolvedRouteHandler> = new Collection<ResolvedRouteHandler>()
|
||||
|
||||
/** Pre-compiled route handler for the main route handler for this route. */
|
||||
protected compiledPostflight?: ResolvedRouteHandler[]
|
||||
protected aliases: Collection<string> = new Collection<string>()
|
||||
|
||||
/** Programmatic aliases of this route. */
|
||||
public aliases: string[] = []
|
||||
handler?: Constructable<(...x: THandlerParams) => TReturn>
|
||||
|
||||
protected displays: Collection<{stage: 'pre'|'post'|'handler', display: string}> = new Collection()
|
||||
|
||||
constructor(
|
||||
/** The HTTP method(s) that this route listens on. */
|
||||
protected method: HTTPMethod | HTTPMethod[],
|
||||
|
||||
/** The primary handler of this route. */
|
||||
protected readonly handler: RouteHandler,
|
||||
|
||||
/** The route path this route listens on. */
|
||||
protected route: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set a programmatic name for this route.
|
||||
@@ -248,24 +208,40 @@ export class Route extends AppClass {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form method of the route.
|
||||
* Get the string-form methods supported by the route.
|
||||
*/
|
||||
public getMethod(): HTTPMethod | HTTPMethod[] {
|
||||
public getMethods(): HTTPMethod[] {
|
||||
if ( !Array.isArray(this.method) ) {
|
||||
return [this.method]
|
||||
}
|
||||
|
||||
return this.method
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection of applied middlewares.
|
||||
* Get preflight middleware for this route.
|
||||
*/
|
||||
public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
|
||||
return this.middlewares.clone()
|
||||
public getPreflight(): Collection<ResolvedRouteHandler> {
|
||||
return this.preflight.clone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form of the route handler.
|
||||
* Get postflight middleware for this route.
|
||||
*/
|
||||
public getDisplayableHandler(): string {
|
||||
return typeof this.handler === 'string' ? this.handler : '(anonymous function)'
|
||||
public getPostflight(): Collection<ResolvedRouteHandler> {
|
||||
return this.postflight.clone()
|
||||
}
|
||||
|
||||
public getParameters(): Collection<ParameterProvidingMiddleware<unknown>> {
|
||||
return this.parameters.clone()
|
||||
}
|
||||
|
||||
public getDisplays(): Collection<{ stage: 'pre'|'handler'|'post', display: string }> {
|
||||
return this.displays.clone()
|
||||
}
|
||||
|
||||
public getHandlerDisplay(): Maybe<string> {
|
||||
return this.displays.firstWhere('stage', '=', 'handler')?.display
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,59 +310,124 @@ export class Route extends AppClass {
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to pre-compile and return the preflight handlers for this route.
|
||||
*/
|
||||
public resolvePreflight(): ResolvedRouteHandler[] {
|
||||
if ( !this.compiledPreflight ) {
|
||||
this.compiledPreflight = this.resolveMiddlewareHandlersForStage('pre')
|
||||
}
|
||||
public parameterMiddleware<T>(
|
||||
handler: ParameterProvidingMiddleware<T>,
|
||||
): Route<TReturn, PrefixTypeArray<T, THandlerParams>> {
|
||||
const route = new Route<TReturn, PrefixTypeArray<T, THandlerParams>>(
|
||||
this.method,
|
||||
this.route,
|
||||
)
|
||||
|
||||
return this.compiledPreflight
|
||||
route.copyFrom(this)
|
||||
route.parameters.push(handler)
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to pre-compile and return the postflight handlers for this route.
|
||||
*/
|
||||
public resolvePostflight(): ResolvedRouteHandler[] {
|
||||
if ( !this.compiledPostflight ) {
|
||||
this.compiledPostflight = this.resolveMiddlewareHandlersForStage('post')
|
||||
}
|
||||
|
||||
return this.compiledPostflight
|
||||
private copyFrom(other: Route<TReturn, any>) {
|
||||
this.preflight = other.preflight.clone()
|
||||
this.postflight = other.postflight.clone()
|
||||
this.aliases = other.aliases.clone()
|
||||
this.displays = other.displays.clone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to pre-compile and return the main handler for this route.
|
||||
*/
|
||||
public resolveHandler(): ResolvedRouteHandler {
|
||||
if ( !this.compiledHandler ) {
|
||||
this.compiledHandler = this.compileResolvedHandler()
|
||||
}
|
||||
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(selector(inst), inst as any) as ((...params: THandlerParams) => TReturn))
|
||||
|
||||
return this.compiledHandler
|
||||
this.displays.push({
|
||||
stage: 'handler',
|
||||
display: `${key.name}(${selector})`,
|
||||
})
|
||||
|
||||
Route.registeredRoutes.push(this as unknown as Route<unknown, unknown[]>) // man this is stupid
|
||||
return this as HandledRoute<TReturn, THandlerParams>
|
||||
}
|
||||
|
||||
/** Register the given middleware as a preflight handler for this route. */
|
||||
pre(middleware: RouteHandler): this {
|
||||
this.middlewares.push({
|
||||
public handledBy(
|
||||
handler: (...params: THandlerParams) => TReturn,
|
||||
): HandledRoute<TReturn, THandlerParams> {
|
||||
this.handler = Pipeline.id<Container>()
|
||||
.tap(() => handler)
|
||||
|
||||
this.displays.push({
|
||||
stage: 'handler',
|
||||
display: `(closure)`,
|
||||
})
|
||||
|
||||
Route.registeredRoutes.push(this as unknown as Route<unknown, unknown[]>)
|
||||
return this as HandledRoute<TReturn, THandlerParams>
|
||||
}
|
||||
|
||||
public pre(middleware: Instantiable<Middleware>): this {
|
||||
this.preflight.prepend(request => request.make<Middleware>(middleware, request).apply())
|
||||
this.displays.push({
|
||||
stage: 'pre',
|
||||
handler: middleware,
|
||||
display: `${middleware.name}`,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/** Register the given middleware as a postflight handler for this route. */
|
||||
post(middleware: RouteHandler): this {
|
||||
this.middlewares.push({
|
||||
stage: 'post',
|
||||
handler: middleware,
|
||||
public post(middleware: Instantiable<Middleware>): this {
|
||||
this.postflight.push(request => request.make<Middleware>(middleware, request).apply())
|
||||
this.displays.push({
|
||||
stage: 'pre',
|
||||
display: `${middleware.name}`,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
public input<T>(validator: ValidatorFactory<T>): Route<TReturn, PrefixTypeArray<Valid<T>, THandlerParams>> {
|
||||
if ( !(validator instanceof Validator) ) {
|
||||
validator = validator()
|
||||
}
|
||||
|
||||
this.displays.push({
|
||||
stage: 'pre',
|
||||
display: `input(${validator.constructor.name})`,
|
||||
})
|
||||
|
||||
return this.parameterMiddleware(validateMiddleware(validator))
|
||||
}
|
||||
|
||||
public passingRequest(): Route<TReturn, PrefixTypeArray<Request, THandlerParams>> {
|
||||
return this.parameterMiddleware(request => right(request))
|
||||
}
|
||||
|
||||
hasAlias(name: string): boolean {
|
||||
return this.aliases.includes(name)
|
||||
}
|
||||
|
||||
getAlias(): Maybe<string> {
|
||||
return this.aliases.first()
|
||||
}
|
||||
|
||||
isHandled(): this is HandledRoute<TReturn, THandlerParams> {
|
||||
return Boolean(this.handler)
|
||||
}
|
||||
|
||||
/** Cast the route to an intelligible string. */
|
||||
toString(): string {
|
||||
const method = Array.isArray(this.method) ? this.method : [this.method]
|
||||
return `${method.join('|')} -> ${this.route}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new Pipe of this collection.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/** Prefix the route's path with the given prefix, normalizing `/` characters. */
|
||||
private prepend(prefix: string): this {
|
||||
if ( !prefix.endsWith('/') ) {
|
||||
@@ -398,111 +439,4 @@ export class Route extends AppClass {
|
||||
this.route = `${prefix}${this.route}`
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add the given middleware item to the beginning of the preflight handlers. */
|
||||
private prependMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }): void {
|
||||
this.middlewares.prepend(def)
|
||||
}
|
||||
|
||||
/** Add the given middleware item to the end of the postflight handlers. */
|
||||
private appendMiddleware(def: { stage: 'pre' | 'post', handler: RouteHandler }): void {
|
||||
this.middlewares.push(def)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return the route handler for this route.
|
||||
* @private
|
||||
*/
|
||||
private compileResolvedHandler(): ResolvedRouteHandler {
|
||||
const handler = this.handler
|
||||
if ( typeof handler !== 'string' ) {
|
||||
return (request: Request) => {
|
||||
return handler(request, request.response)
|
||||
}
|
||||
} else {
|
||||
const parts = handler.split('.')
|
||||
if ( parts.length < 2 ) {
|
||||
const e = new ErrorWithContext('Route handler does not specify a method name.')
|
||||
e.context = {
|
||||
handler,
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const [controllerName, methodName] = parts
|
||||
|
||||
const controllersService = <Controllers> this.make(Controllers)
|
||||
const controllerClass = controllersService.get(controllerName)
|
||||
if ( !controllerClass ) {
|
||||
const e = new ErrorWithContext('Controller not found for route handler.')
|
||||
e.context = {
|
||||
handler,
|
||||
controllerName,
|
||||
methodName,
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return (request: Request) => {
|
||||
// If not a function, then we got a string reference to a controller method
|
||||
// So, we need to use the request container to instantiate the controller
|
||||
// and bind the method
|
||||
const controller = <Controller> request.make(controllerClass, request)
|
||||
const method = controller.getBoundMethod(methodName)
|
||||
return method()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return the route handlers for the given pre- or post-flight stage.
|
||||
* @param stage
|
||||
* @private
|
||||
*/
|
||||
private resolveMiddlewareHandlersForStage(stage: 'pre' | 'post'): ResolvedRouteHandler[] {
|
||||
return this.middlewares.where('stage', '=', stage)
|
||||
.map<ResolvedRouteHandler>(def => {
|
||||
const handler = def.handler
|
||||
if ( typeof handler !== 'string' ) {
|
||||
return (request: Request) => {
|
||||
return handler(request, request.response)
|
||||
}
|
||||
} else {
|
||||
const parts = handler.split('.')
|
||||
if ( parts.length < 2 ) {
|
||||
parts.push('apply') // default middleware method name, if none provided
|
||||
}
|
||||
|
||||
const [middlewareName, methodName] = parts
|
||||
|
||||
const middlewaresService = <Middlewares> this.make(Middlewares)
|
||||
const middlewareClass = middlewaresService.get(middlewareName)
|
||||
if ( !middlewareClass ) {
|
||||
const e = new ErrorWithContext('Middleware not found for route handler.')
|
||||
e.context = {
|
||||
handler,
|
||||
middlewareName,
|
||||
methodName,
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return (request: Request) => {
|
||||
// If not a function, then we got a string reference to a middleware method
|
||||
// So, we need to use the request container to instantiate the middleware
|
||||
// and bind the method
|
||||
const middleware = <Middleware> request.make(middlewareClass, request)
|
||||
const method = middleware.getBoundMethod(methodName)
|
||||
return method()
|
||||
}
|
||||
}
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
/** Cast the route to an intelligible string. */
|
||||
toString(): string {
|
||||
const method = Array.isArray(this.method) ? this.method : [this.method]
|
||||
return `${method.join('|')} -> ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {Collection, ErrorWithContext} from '../../util'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {RouteHandler} from './Route'
|
||||
import {Container} from '../../di'
|
||||
import {Container, Instantiable} from '../../di'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Middleware} from './Middleware'
|
||||
|
||||
/**
|
||||
* Class that defines a group of Routes in the application, with a prefix.
|
||||
*/
|
||||
export class RouteGroup extends AppClass {
|
||||
protected preflight: Collection<Instantiable<Middleware>> = new Collection()
|
||||
|
||||
protected postflight: Collection<Instantiable<Middleware>> = new Collection()
|
||||
|
||||
/**
|
||||
* The current set of nested groups. This is used when compiling route groups.
|
||||
* @private
|
||||
@@ -20,12 +24,6 @@ export class RouteGroup extends AppClass {
|
||||
*/
|
||||
protected static namedGroups: {[key: string]: () => void } = {}
|
||||
|
||||
/**
|
||||
* Array of middlewares that should apply to all routes in this group.
|
||||
* @protected
|
||||
*/
|
||||
protected middlewares: Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> = new Collection<{stage: 'pre' | 'post'; handler: RouteHandler}>()
|
||||
|
||||
/**
|
||||
* Get the current group nesting.
|
||||
*/
|
||||
@@ -89,27 +87,23 @@ export class RouteGroup extends AppClass {
|
||||
}
|
||||
|
||||
/** Register the given middleware to be applied before all routes in this group. */
|
||||
pre(middleware: RouteHandler): this {
|
||||
this.middlewares.push({
|
||||
stage: 'pre',
|
||||
handler: middleware,
|
||||
})
|
||||
|
||||
pre(middleware: Instantiable<Middleware>): this {
|
||||
this.preflight.prepend(middleware)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Register the given middleware to be applied after all routes in this group. */
|
||||
post(middleware: RouteHandler): this {
|
||||
this.middlewares.push({
|
||||
stage: 'post',
|
||||
handler: middleware,
|
||||
})
|
||||
|
||||
post(middleware: Instantiable<Middleware>): this {
|
||||
this.postflight.push(middleware)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Return the middlewares that apply to this group. */
|
||||
getGroupMiddlewareDefinitions(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
|
||||
return this.middlewares
|
||||
getPreflight(): Collection<Instantiable<Middleware>> {
|
||||
return this.preflight
|
||||
}
|
||||
|
||||
getPostflight(): Collection<Instantiable<Middleware>> {
|
||||
return this.postflight
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {HTTPError} from '../HTTPError'
|
||||
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
|
||||
import {redirect} from '../response/RedirectResponseFactory'
|
||||
import {file} from '../response/FileResponseFactory'
|
||||
import {RouteHandler} from '../routing/Route'
|
||||
import {ResponseObject} from '../routing/Route'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Defines the behavior of the static server.
|
||||
@@ -109,11 +110,12 @@ function getBasePath(appPath: UniversalPath, basePath?: string | string[] | Univ
|
||||
* Get a route handler that serves a directory as static files.
|
||||
* @param options
|
||||
*/
|
||||
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||
export function staticServer(options: StaticServerOptions = {}): (request: Request) => Promise<ResponseObject> {
|
||||
return async (request: Request) => {
|
||||
const config = <Config> request.make(Config)
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
||||
const app = <Application> request.make(Application)
|
||||
const logging = <Logging> request.make(Logging)
|
||||
|
||||
const staticConfig = config.get('server.builtIns.static', {})
|
||||
const mergedOptions = {
|
||||
@@ -183,6 +185,7 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||
}
|
||||
|
||||
// Otherwise, just send the file as the response body
|
||||
logging.verbose(`Sending file: ${filePath}`)
|
||||
return file(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Injectable, Inject} from '../../di'
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {ErrorWithContext, Safe} from '../../util'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
|
||||
/**
|
||||
@@ -60,4 +60,9 @@ export abstract class Session {
|
||||
|
||||
/** Remove a key from the session data. */
|
||||
public abstract forget(key: string): void
|
||||
|
||||
/** Load a key from the session as a Safe value. */
|
||||
public safe(key: string): Safe {
|
||||
return new Safe(this.get(key))
|
||||
}
|
||||
}
|
||||
|
||||
20
src/index.ts
20
src/index.ts
@@ -2,18 +2,20 @@ export * from './util'
|
||||
export * from './lib'
|
||||
export * from './di'
|
||||
|
||||
export * from './event/types'
|
||||
export * from './event/Event'
|
||||
export * from './event/EventBus'
|
||||
export * from './event/PropagatingEventBus'
|
||||
|
||||
export * from './service/Logging'
|
||||
|
||||
export * from './support/bus/index'
|
||||
|
||||
export * from './lifecycle/RunLevelErrorHandler'
|
||||
export * from './lifecycle/Application'
|
||||
export * from './lifecycle/AppClass'
|
||||
export * from './lifecycle/Unit'
|
||||
|
||||
export * from './validation/ZodifyRecipient'
|
||||
export * from './validation/ZodifyRegistrar'
|
||||
export * from './validation/Validator'
|
||||
export * from './validation/ValidationUnit'
|
||||
|
||||
export * from './http/HTTPError'
|
||||
|
||||
export * from './http/kernel/module/InjectSessionHTTPModule'
|
||||
@@ -25,6 +27,7 @@ export * from './http/kernel/module/AbstractResolvedRouteHandlerHTTPModule'
|
||||
export * from './http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule'
|
||||
export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule'
|
||||
export * from './http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule'
|
||||
export * from './http/kernel/module/ClearRequestEventBusHTTPModule'
|
||||
|
||||
export * from './http/kernel/HTTPKernel'
|
||||
export * from './http/kernel/HTTPKernelModule'
|
||||
@@ -32,6 +35,9 @@ export * from './http/kernel/HTTPCookieJar'
|
||||
|
||||
export * from './http/lifecycle/Request'
|
||||
export * from './http/lifecycle/Response'
|
||||
export * from './http/RequestLocalStorage'
|
||||
|
||||
export * from './make'
|
||||
|
||||
export * as api from './http/response/api'
|
||||
export * from './http/response/DehydratedStateResponseFactory'
|
||||
@@ -76,13 +82,13 @@ export * from './service/Files'
|
||||
export * from './service/HTTPServer'
|
||||
export * from './service/Routing'
|
||||
export * from './service/Middlewares'
|
||||
export * from './service/Discovery'
|
||||
|
||||
export * from './support/redis/Redis'
|
||||
export * from './support/cache/MemoryCache'
|
||||
export * from './support/cache/RedisCache'
|
||||
export * from './support/cache/CacheFactory'
|
||||
export * from './support/NodeModules'
|
||||
export * from './support/queue/Queue'
|
||||
|
||||
export * from './service/Queueables'
|
||||
|
||||
@@ -92,6 +98,6 @@ export * from './views/PugViewEngine'
|
||||
|
||||
export * from './cli'
|
||||
export * from './i18n'
|
||||
export * from './forms'
|
||||
// export * from './forms'
|
||||
export * from './orm'
|
||||
export * from './auth'
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {Container} from '../di'
|
||||
import {
|
||||
ErrorWithContext,
|
||||
FileLogger,
|
||||
globalRegistry,
|
||||
ifDebugging,
|
||||
infer,
|
||||
isLoggingLevel,
|
||||
LoggingLevel,
|
||||
logIfDebugging,
|
||||
PathLike,
|
||||
StandardLogger,
|
||||
universalPath,
|
||||
@@ -12,9 +16,9 @@ import {
|
||||
import {Logging} from '../service/Logging'
|
||||
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
||||
import {Unit, UnitStatus} from './Unit'
|
||||
import * as fs from 'fs'
|
||||
import * as dotenv from 'dotenv'
|
||||
import {CacheFactory} from '../support/cache/CacheFactory'
|
||||
import {FileLogger} from '../util/logging/FileLogger'
|
||||
|
||||
/**
|
||||
* Helper function that resolves and infers environment variable values.
|
||||
@@ -220,6 +224,7 @@ export class Application extends Container {
|
||||
logging.registerLogger(file)
|
||||
}
|
||||
|
||||
logging.level = LoggingLevel.Verbose
|
||||
logging.verbose('Attempting to load logging level from the environment...')
|
||||
|
||||
const envLevel = this.env('EXTOLLO_LOGGING_LEVEL')
|
||||
@@ -237,9 +242,14 @@ export class Application extends Container {
|
||||
* @protected
|
||||
*/
|
||||
protected bootstrapEnvironment(): void {
|
||||
dotenv.config({
|
||||
path: this.basePath.concat('.env').toLocal,
|
||||
})
|
||||
logIfDebugging('extollo.env', `.env path: ${this.basePath.concat('.env').toLocal}`)
|
||||
const path = this.basePath.concat('.env').toLocal
|
||||
if ( fs.existsSync(path) ) {
|
||||
dotenv.config({
|
||||
path,
|
||||
})
|
||||
}
|
||||
logIfDebugging('extollo.env', process.env)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,6 +268,13 @@ export class Application extends Container {
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
await this.up()
|
||||
|
||||
ifDebugging('extollo.wontstop', () => {
|
||||
setTimeout(() => {
|
||||
import('wtfnode').then(wtf => wtf.dump())
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
await this.down()
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
|
||||
13
src/make.ts
Normal file
13
src/make.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {TypedDependencyKey} from './di'
|
||||
import {Application} from './lifecycle/Application'
|
||||
import {RequestLocalStorage} from './http/RequestLocalStorage'
|
||||
|
||||
export function make<T>(key: TypedDependencyKey<T>): T {
|
||||
const container = Application.getApplication()
|
||||
const rls = container.make<RequestLocalStorage>(RequestLocalStorage)
|
||||
if ( rls.has() ) {
|
||||
return rls.get().make(key)
|
||||
}
|
||||
|
||||
return container.make(key)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import {QueryResult} from '../types'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {EventBus} from '../../event/EventBus'
|
||||
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
|
||||
import {Schema} from '../schema/Schema'
|
||||
import {Bus} from '../../support/bus'
|
||||
|
||||
/**
|
||||
* Error thrown when a connection is used before it is ready.
|
||||
@@ -25,7 +25,7 @@ export class ConnectionNotReadyError extends ErrorWithContext {
|
||||
@Injectable()
|
||||
export abstract class Connection extends AppClass {
|
||||
@Inject()
|
||||
protected bus!: EventBus
|
||||
protected readonly bus!: Bus
|
||||
|
||||
constructor(
|
||||
/**
|
||||
@@ -82,6 +82,6 @@ export abstract class Connection extends AppClass {
|
||||
*/
|
||||
protected async queryExecuted(query: string): Promise<void> {
|
||||
const event = new QueryExecutedEvent(this.name, this, query)
|
||||
await this.bus.dispatch(event)
|
||||
await this.bus.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user