Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
39d97d6e14
|
|||
|
f496046461
|
|||
|
b3b5b169e8
|
|||
|
5d960e6186
|
|||
|
cf6d14abca
|
|||
|
faa8a31102
|
|||
|
7506d6567d
|
|||
|
a69c81ed35
|
|||
|
36b451c32b
|
|||
|
9796a7277e
|
|||
|
f00233d49a
|
|||
|
91abcdf8ef
|
|||
|
c264d45927
|
|||
|
61731c4ebd
|
|||
|
dab3d006c8
|
|||
|
cd9bec7c5e
|
15
.drone.yml
15
.drone.yml
@@ -217,18 +217,3 @@ steps:
|
||||
when:
|
||||
status: failure
|
||||
event: pull_request
|
||||
|
||||
- name: trigger documentation build
|
||||
image: plugins/downstream
|
||||
settings:
|
||||
server: https://ci.garrettmills.dev
|
||||
token:
|
||||
from_secret: drone_token
|
||||
fork: false
|
||||
last_successful: true
|
||||
deploy: production
|
||||
repositories:
|
||||
- Extollo/docs@master
|
||||
when:
|
||||
status: success
|
||||
event: tag
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -8,19 +8,26 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atao60/fse-cli": "^0.1.6",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/node": "^14.17.4",
|
||||
"@types/pg": "^8.6.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pug": "^2.0.4",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/ssh2": "^0.5.46",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"busboy": "^0.3.1",
|
||||
"cli-table": "^0.3.6",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
"pg": "^8.6.0",
|
||||
@@ -38,8 +45,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prebuild": "pnpm run lint",
|
||||
"prebuild": "pnpm run lint && rimraf lib",
|
||||
"build": "tsc",
|
||||
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build",
|
||||
"docs:build": "typedoc --options typedoc.json",
|
||||
@@ -61,5 +69,11 @@
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||
"@typescript-eslint/parser": "^4.26.0",
|
||||
"eslint": "^7.27.0"
|
||||
},
|
||||
"extollo": {
|
||||
"discover": true,
|
||||
"units": {
|
||||
"discover": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
661
pnpm-lock.yaml
generated
661
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
40
src/auth/Authentication.ts
Normal file
40
src/auth/Authentication.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {Logging} from '../service/Logging'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Unit class that bootstraps the authentication framework.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Authentication extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly middleware!: Middlewares
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.container()
|
||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the canonical namespace resolver for auth middleware.
|
||||
* @protected
|
||||
*/
|
||||
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
||||
return (key: string) => {
|
||||
return ({
|
||||
web: SessionAuthMiddleware,
|
||||
required: AuthRequiredMiddleware,
|
||||
guest: GuestRequiredMiddleware,
|
||||
})[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/auth/NotAuthorizedError.ts
Normal file
11
src/auth/NotAuthorizedError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {HTTPError} from '../http/HTTPError'
|
||||
import {HTTPStatus} from '../util'
|
||||
|
||||
/**
|
||||
* Error thrown when a user attempts an action that they are not authorized to perform.
|
||||
*/
|
||||
export class NotAuthorizedError extends HTTPError {
|
||||
constructor(message = 'Not Authorized') {
|
||||
super(HTTPStatus.FORBIDDEN, message)
|
||||
}
|
||||
}
|
||||
143
src/auth/SecurityContext.ts
Normal file
143
src/auth/SecurityContext.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
/** 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: Record<string, string>): 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: Record<string, string>): 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()
|
||||
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<Record<string, string>>
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
25
src/auth/config.ts
Normal file
25
src/auth/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
31
src/auth/contexts/SessionSecurityContext.ts
Normal file
31
src/auth/contexts/SessionSecurityContext.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {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<Record<string, string>> {
|
||||
return {
|
||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
persist(): Awaitable<void> {
|
||||
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserAuthenticatedEvent.ts
Normal file
27
src/auth/event/UserAuthenticatedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
27
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* Event fired when a security context for a given user is resumed.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserFlushedEvent.ts
Normal file
27
src/auth/event/UserFlushedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
21
src/auth/index.ts
Normal file
21
src/auth/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export * from './types'
|
||||
export * from './NotAuthorizedError'
|
||||
|
||||
export * from './SecurityContext'
|
||||
|
||||
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 './middleware/AuthRequiredMiddleware'
|
||||
export * from './middleware/GuestRequiredMiddleware'
|
||||
export * from './middleware/SessionAuthMiddleware'
|
||||
|
||||
export * from './Authentication'
|
||||
|
||||
export * from './config'
|
||||
19
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
19
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
|
||||
@Injectable()
|
||||
export class AuthRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( !this.security.hasUser() ) {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
19
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
|
||||
@Injectable()
|
||||
export class GuestRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( this.security.hasUser() ) {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
40
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Injects a SessionSecurityContext into the request and attempts to
|
||||
* resume the user's authentication.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionAuthMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying session auth middleware...')
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the correct AuthenticatableRepository based on the auth config.
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
64
src/auth/orm/ORMUser.ts
Normal file
64
src/auth/orm/ORMUser.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* A basic ORM-driven user class.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
|
||||
protected static table = 'users'
|
||||
|
||||
protected static key = 'user_id'
|
||||
|
||||
/** The primary key of the user in the table. */
|
||||
@Field(FieldType.serial, 'user_id')
|
||||
public userId!: number
|
||||
|
||||
/** The unique string-identifier of the user. */
|
||||
@Field(FieldType.varchar)
|
||||
public username!: string
|
||||
|
||||
/** The user's first name. */
|
||||
@Field(FieldType.varchar, 'first_name')
|
||||
public firstName!: string
|
||||
|
||||
/** The user's last name. */
|
||||
@Field(FieldType.varchar, 'last_name')
|
||||
public lastName!: string
|
||||
|
||||
/** The hashed and salted password of the user. */
|
||||
@Field(FieldType.varchar, 'password_hash')
|
||||
public passwordHash!: string
|
||||
|
||||
/** Human-readable display name of the user. */
|
||||
getDisplayIdentifier(): string {
|
||||
return `${this.firstName} ${this.lastName}`
|
||||
}
|
||||
|
||||
/** Unique identifier of the user. */
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return this.username
|
||||
}
|
||||
|
||||
/** Check if the provided password is valid for the user. */
|
||||
verifyPassword(password: string): Awaitable<boolean> {
|
||||
return bcrypt.compare(password, this.passwordHash)
|
||||
}
|
||||
|
||||
/** Change the user's password, hashing it. */
|
||||
async setPassword(password: string): Promise<void> {
|
||||
this.passwordHash = await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return this.toQueryRow()
|
||||
}
|
||||
|
||||
async rehydrate(state: JSONState): Promise<void> {
|
||||
await this.assumeFromSource(state)
|
||||
}
|
||||
}
|
||||
41
src/auth/orm/ORMUserRepository.ts
Normal file
41
src/auth/orm/ORMUserRepository.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Injectable} from '../../di'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
/** 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: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
if ( credentials.securityIdentifier ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.securityIdentifier)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.username && credentials.password ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.username)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/auth/types.ts
Normal file
36
src/auth/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
export abstract class Authenticatable implements Rehydratable {
|
||||
|
||||
/** Get the unique identifier of the user. */
|
||||
abstract getIdentifier(): AuthenticatableIdentifier
|
||||
|
||||
/** Get the human-readable identifier of the user. */
|
||||
abstract getDisplayIdentifier(): string
|
||||
|
||||
abstract dehydrate(): Promise<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
||||
}
|
||||
71
src/cli/directive/RouteDirective.ts
Normal file
71
src/cli/directive/RouteDirective.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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'
|
||||
|
||||
@Injectable()
|
||||
export class RouteDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
getDescription(): string {
|
||||
return 'Get information about a specific route'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['route']
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return [
|
||||
'{route} | the path of the route',
|
||||
'--method -m {value} | the HTTP method of the route',
|
||||
]
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const method: string | undefined = this.option('method')
|
||||
?.toLowerCase()
|
||||
?.trim()
|
||||
|
||||
const route: string = this.option('route')
|
||||
.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
|
||||
}
|
||||
})
|
||||
.each(match => {
|
||||
const pre = match.getMiddlewares()
|
||||
.where('stage', '=', 'pre')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
|
||||
const post = match.getMiddlewares()
|
||||
.where('stage', '=', 'post')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
|
||||
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
|
||||
|
||||
const table = new Table({
|
||||
head: ['Stage', 'Handler'],
|
||||
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
|
||||
})
|
||||
|
||||
table.push(...pre.toArray())
|
||||
table.push(['handler', match.getDisplayableHandler()])
|
||||
table.push(...post.toArray())
|
||||
|
||||
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||
})
|
||||
}
|
||||
|
||||
protected handlerToString(handler: RouteHandler): string {
|
||||
return typeof handler === 'string' ? handler : '(anonymous function)'
|
||||
}
|
||||
}
|
||||
33
src/cli/directive/RoutesDirective.ts
Normal file
33
src/cli/directive/RoutesDirective.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Directive} from '../Directive'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import Table = require('cli-table')
|
||||
|
||||
@Injectable()
|
||||
export class RoutesDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
getDescription(): string {
|
||||
return 'List routes registered in the application'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['routes']
|
||||
}
|
||||
|
||||
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 table = new Table({
|
||||
head: ['Route', 'Handler'],
|
||||
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
|
||||
})
|
||||
|
||||
table.push(...rows.toArray())
|
||||
|
||||
this.info('\n' + table)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export class ShellDirective extends Directive {
|
||||
async handle(): Promise<void> {
|
||||
const state: any = {
|
||||
app: this.app(),
|
||||
lib: await import('../../index'),
|
||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {Directive} from '../Directive'
|
||||
import {ShellDirective} from '../directive/ShellDirective'
|
||||
import {TemplateDirective} from '../directive/TemplateDirective'
|
||||
import {RunDirective} from '../directive/RunDirective'
|
||||
import {RoutesDirective} from '../directive/RoutesDirective'
|
||||
import {RouteDirective} from '../directive/RouteDirective'
|
||||
|
||||
/**
|
||||
* Unit that takes the place of the final unit in the application that handles
|
||||
@@ -42,6 +44,8 @@ export class CommandLineApplication extends Unit {
|
||||
this.cli.registerDirective(ShellDirective)
|
||||
this.cli.registerDirective(TemplateDirective)
|
||||
this.cli.registerDirective(RunDirective)
|
||||
this.cli.registerDirective(RoutesDirective)
|
||||
this.cli.registerDirective(RouteDirective)
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const match = this.cli.getDirectives()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable} from './types'
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
@@ -7,6 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import SingletonFactory from './factory/SingletonFactory'
|
||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||
import {ContainerBlueprint} from './ContainerBlueprint'
|
||||
|
||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
@@ -23,6 +24,11 @@ export class Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Container()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -47,6 +53,25 @@ export class Container {
|
||||
this.registerSingleton('injector', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all factories and instances of the given key from this container.
|
||||
* @param key
|
||||
*/
|
||||
purge(key: DependencyKey): this {
|
||||
this.factories = this.factories.filter(x => !x.match(key))
|
||||
this.release(key)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all stored instances of the given key from this container.
|
||||
* @param key
|
||||
*/
|
||||
release(key: DependencyKey): this {
|
||||
this.instances = this.instances.filter(x => x.key !== key)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
@@ -113,7 +138,7 @@ export class Container {
|
||||
* @param staticClass
|
||||
* @param instance
|
||||
*/
|
||||
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T): this {
|
||||
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||
if ( this.resolve(staticClass) ) {
|
||||
throw new DuplicateFactoryKeyError(staticClass)
|
||||
}
|
||||
|
||||
53
src/di/ContainerBlueprint.ts
Normal file
53
src/di/ContainerBlueprint.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {DependencyKey, Instantiable} from './types'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
|
||||
export class ContainerBlueprint {
|
||||
private static instance?: ContainerBlueprint
|
||||
|
||||
public static getContainerBlueprint(): ContainerBlueprint {
|
||||
if ( !this.instance ) {
|
||||
this.instance = new ContainerBlueprint()
|
||||
}
|
||||
|
||||
return this.instance
|
||||
}
|
||||
|
||||
protected factories: (() => AbstractFactory<any>)[] = []
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||
this.factories.push(() => new NamedFactory(name, dependency))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>): this {
|
||||
this.factories.push(() => new Factory(dependency))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a producer function as a ClosureFactory with this container.
|
||||
* @param key
|
||||
* @param producer
|
||||
*/
|
||||
registerProducer(key: DependencyKey, producer: () => any): this {
|
||||
this.factories.push(() => new ClosureFactory(key, producer))
|
||||
return this
|
||||
}
|
||||
|
||||
resolve(): AbstractFactory<any>[] {
|
||||
return this.factories.map(x => x())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Container, MaybeDependency, MaybeFactory} from './Container'
|
||||
import {DependencyKey} from './types'
|
||||
import {DependencyKey, Instantiable, StaticClass} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
|
||||
/**
|
||||
* A container that uses some parent container as a base, but
|
||||
@@ -30,6 +31,8 @@ export class ScopedContainer extends Container {
|
||||
return new ScopedContainer(container)
|
||||
}
|
||||
|
||||
private resolveParentScope = true
|
||||
|
||||
constructor(
|
||||
private parentContainer: Container,
|
||||
) {
|
||||
@@ -38,11 +41,11 @@ export class ScopedContainer extends Container {
|
||||
}
|
||||
|
||||
hasInstance(key: DependencyKey): boolean {
|
||||
return super.hasInstance(key) || this.parentContainer.hasInstance(key)
|
||||
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
|
||||
}
|
||||
|
||||
hasKey(key: DependencyKey): boolean {
|
||||
return super.hasKey(key) || this.parentContainer.hasKey(key)
|
||||
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
|
||||
}
|
||||
|
||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||
@@ -51,8 +54,10 @@ export class ScopedContainer extends Container {
|
||||
return inst
|
||||
}
|
||||
|
||||
if ( this.resolveParentScope ) {
|
||||
return this.parentContainer.getExistingInstance(key)
|
||||
}
|
||||
}
|
||||
|
||||
resolve(key: DependencyKey): MaybeFactory<any> {
|
||||
const factory = super.resolve(key)
|
||||
@@ -60,6 +65,77 @@ export class ScopedContainer extends Container {
|
||||
return factory
|
||||
}
|
||||
|
||||
return this.parentContainer?.resolve(key)
|
||||
if ( this.resolveParentScope ) {
|
||||
return this.parentContainer.resolve(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>): this {
|
||||
return this.withoutParentScopes(() => super.register(dependency))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given function as a factory within the container.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {function} producer - factory to produce a value
|
||||
*/
|
||||
registerProducer(name: DependencyKey, producer: () => any): this {
|
||||
return this.withoutParentScopes(() => super.registerProducer(name, producer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a value as a singleton in the container. It will not be instantiated, but
|
||||
* can be injected by its unique name.
|
||||
* @param {string} key - unique name to identify the singleton in the container
|
||||
* @param value
|
||||
*/
|
||||
registerSingleton<T>(key: DependencyKey, value: T): this {
|
||||
return this.withoutParentScopes(() => super.registerSingleton(key, value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a static class to the container along with its already-instantiated
|
||||
* instance that will be used to resolve the class.
|
||||
* @param staticClass
|
||||
* @param instance
|
||||
*/
|
||||
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a given factory with the container.
|
||||
* @param {AbstractFactory} factory
|
||||
*/
|
||||
registerFactory(factory: AbstractFactory<unknown>): this {
|
||||
return this.withoutParentScopes(() => super.registerFactory(factory))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a closure on this container, disabling parent-resolution.
|
||||
* Effectively, the closure will have access to this container as if
|
||||
* it were NOT a scoped container, and only contained its factories.
|
||||
* @param closure
|
||||
*/
|
||||
withoutParentScopes<T>(closure: () => T): T {
|
||||
const oldResolveParentScope = this.resolveParentScope
|
||||
this.resolveParentScope = false
|
||||
const value: T = closure()
|
||||
this.resolveParentScope = oldResolveParentScope
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||
PropertyDependency,
|
||||
} from '../types'
|
||||
import {Container} from '../Container'
|
||||
import {ContainerBlueprint} from '../ContainerBlueprint'
|
||||
|
||||
/**
|
||||
* Get a collection of dependency requirements for the given target object.
|
||||
@@ -145,9 +145,9 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
Injectable()(target)
|
||||
|
||||
if ( name ) {
|
||||
Container.getContainer().registerNamed(name, target)
|
||||
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
|
||||
} else {
|
||||
Container.getContainer().register(target)
|
||||
ContainerBlueprint.getContainerBlueprint().register(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './factory/Factory'
|
||||
export * from './factory/NamedFactory'
|
||||
export * from './factory/SingletonFactory'
|
||||
|
||||
export * from './ContainerBlueprint'
|
||||
export * from './Container'
|
||||
export * from './ScopedContainer'
|
||||
export * from './types'
|
||||
|
||||
13
src/event/Event.ts
Normal file
13
src/event/Event.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
}
|
||||
53
src/event/EventBus.ts
Normal file
53
src/event/EventBus.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {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, 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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/event/PropagatingEventBus.ts
Normal file
28
src/event/PropagatingEventBus.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/event/types.ts
Normal file
47
src/event/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {Awaitable, Rehydratable} from '../util'
|
||||
import {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, 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, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
||||
dispatch(event: Dispatchable): Awaitable<void>
|
||||
}
|
||||
@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
|
||||
constructor(
|
||||
public readonly status: HTTPStatus = 500,
|
||||
public readonly message: string = '',
|
||||
context?: {[key: string]: any},
|
||||
) {
|
||||
super('HTTP ERROR')
|
||||
super('HTTP ERROR', context)
|
||||
this.message = message || HTTPMessage[status]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
|
||||
import {plaintext} from '../../response/StringResponseFactory'
|
||||
import {ResponseFactory} from '../../response/ResponseFactory'
|
||||
import {json} from '../../response/JSONResponseFactory'
|
||||
import {UniversalPath} from '../../../util'
|
||||
import {file} from '../../response/FileResponseFactory'
|
||||
|
||||
/**
|
||||
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
|
||||
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
|
||||
|
||||
if ( object instanceof ResponseFactory ) {
|
||||
await object.write(request)
|
||||
} else if ( object instanceof UniversalPath ) {
|
||||
await file(object).write(request)
|
||||
} else if ( typeof object !== 'undefined' ) {
|
||||
await json(object).write(request)
|
||||
} else {
|
||||
|
||||
28
src/http/kernel/module/InjectRequestEventBusHTTPModule.ts
Normal file
28
src/http/kernel/module/InjectRequestEventBusHTTPModule.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 {EventBus} from '../../../event/EventBus'
|
||||
import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
|
||||
|
||||
/**
|
||||
* HTTP kernel module that creates a request-specific event bus
|
||||
* and injects it into the request container.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
|
||||
@Inject()
|
||||
protected bus!: EventBus
|
||||
|
||||
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)
|
||||
|
||||
request.purge(EventBus).registerProducer(EventBus, () => bus)
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ 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 = new ActivatedRoute(route, request.path)
|
||||
const activated = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
|
||||
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
|
||||
} else {
|
||||
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Request} from './Request'
|
||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
||||
import {ServerResponse} from 'http'
|
||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||
import {Readable} from 'stream'
|
||||
|
||||
/**
|
||||
* Error thrown when the server tries to re-send headers after they have been sent once.
|
||||
@@ -47,7 +48,7 @@ export class Response {
|
||||
private isBlockingWriteback = false
|
||||
|
||||
/** The body contents that should be written to the response. */
|
||||
public body = ''
|
||||
public body: string | Buffer | Uint8Array | Readable = ''
|
||||
|
||||
/**
|
||||
* Behavior subject fired right before the response content is written.
|
||||
@@ -192,11 +193,21 @@ export class Response {
|
||||
* Write the headers and specified data to the client.
|
||||
* @param data
|
||||
*/
|
||||
public async write(data: unknown): Promise<void> {
|
||||
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||
return new Promise<void>((res, rej) => {
|
||||
if ( !this.sentHeaders ) {
|
||||
this.sendHeaders()
|
||||
}
|
||||
|
||||
if ( data instanceof Readable ) {
|
||||
data.pipe(this.serverResponse)
|
||||
.on('finish', () => {
|
||||
res()
|
||||
})
|
||||
.on('error', error => {
|
||||
rej(error)
|
||||
})
|
||||
} else {
|
||||
this.serverResponse.write(data, error => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
@@ -204,6 +215,7 @@ export class Response {
|
||||
res()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,9 +224,14 @@ export class Response {
|
||||
*/
|
||||
public async send(): Promise<void> {
|
||||
await this.sending$.next(this)
|
||||
|
||||
if ( !(this.body instanceof Readable) ) {
|
||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||
}
|
||||
|
||||
await this.write(this.body ?? '')
|
||||
this.end()
|
||||
|
||||
await this.sent$.next(this)
|
||||
}
|
||||
|
||||
|
||||
36
src/http/response/FileResponseFactory.ts
Normal file
36
src/http/response/FileResponseFactory.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {ResponseFactory} from './ResponseFactory'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ErrorWithContext, UniversalPath} from '../../util'
|
||||
|
||||
/**
|
||||
* Helper function that creates a FileResponseFactory for the given path.
|
||||
* @param path
|
||||
*/
|
||||
export function file(path: UniversalPath): FileResponseFactory {
|
||||
return new FileResponseFactory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response factory that sends a file referenced by a given UniversalPath.
|
||||
*/
|
||||
export class FileResponseFactory extends ResponseFactory {
|
||||
constructor(
|
||||
/** The file to be sent. */
|
||||
public readonly path: UniversalPath,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public async write(request: Request): Promise<Request> {
|
||||
if ( !(await this.path.isFile()) ) {
|
||||
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
|
||||
path: this.path,
|
||||
})
|
||||
}
|
||||
|
||||
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
|
||||
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
|
||||
request.response.body = await this.path.readStream()
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {ResolvedRouteHandler, Route} from './Route'
|
||||
import {Injectable} from '../../di'
|
||||
|
||||
/**
|
||||
* Class representing a resolved route that a request is mounted to.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivatedRoute {
|
||||
/**
|
||||
* The parsed params from the route definition.
|
||||
|
||||
@@ -87,11 +87,15 @@ export class Route extends AppClass {
|
||||
for ( const group of stack ) {
|
||||
route.prepend(group.prefix)
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.each(def => route.prependMiddleware(def))
|
||||
.where('stage', '=', 'pre')
|
||||
.each(def => {
|
||||
route.prependMiddleware(def)
|
||||
})
|
||||
}
|
||||
|
||||
for ( const group of this.compiledGroupStack ) {
|
||||
group.getGroupMiddlewareDefinitions()
|
||||
.where('stage', '=', 'post')
|
||||
.each(def => route.appendMiddleware(def))
|
||||
}
|
||||
|
||||
@@ -224,6 +228,34 @@ export class Route extends AppClass {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form of the route.
|
||||
*/
|
||||
public getRoute(): string {
|
||||
return this.route
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form method of the route.
|
||||
*/
|
||||
public getMethod(): HTTPMethod | HTTPMethod[] {
|
||||
return this.method
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection of applied middlewares.
|
||||
*/
|
||||
public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
|
||||
return this.middlewares.clone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string-form of the route handler.
|
||||
*/
|
||||
public getDisplayableHandler(): string {
|
||||
return typeof this.handler === 'string' ? this.handler : '(anonymous function)'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this route matches the given HTTP verb and request path.
|
||||
* @param method
|
||||
|
||||
169
src/http/servers/static.ts
Normal file
169
src/http/servers/static.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ActivatedRoute} from '../routing/ActivatedRoute'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
import {HTTPError} from '../HTTPError'
|
||||
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
|
||||
import {redirect} from '../response/TemporaryRedirectResponseFactory'
|
||||
import {file} from '../response/FileResponseFactory'
|
||||
import {RouteHandler} from '../routing/Route'
|
||||
|
||||
/**
|
||||
* Defines the behavior of the static server.
|
||||
*/
|
||||
export interface StaticServerOptions {
|
||||
/** If true, browsing to a directory route will show the directory listing page. */
|
||||
directoryListing?: boolean
|
||||
|
||||
/** The path to the directory whose files should be served. */
|
||||
basePath?: string | string[] | UniversalPath
|
||||
|
||||
/** If specified, only files with these extensions will be served. */
|
||||
allowedExtensions?: string[]
|
||||
|
||||
/** If specified, files with these extensions will not be served. */
|
||||
excludedExtensions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPError class thrown by the static server.
|
||||
*/
|
||||
export class StaticServerHTTPError extends HTTPError {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response factory that shows the directory listing.
|
||||
* @param dirname
|
||||
* @param dirPath
|
||||
*/
|
||||
async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise<ViewResponseFactory> {
|
||||
return view('@extollo:static:dirlist', {
|
||||
dirname,
|
||||
contents: (await (await dirPath.list())
|
||||
.promiseMap(async path => {
|
||||
const isDirectory = await path.isDirectory()
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.toBase,
|
||||
size: isDirectory ? '-' : await path.sizeForHumans(),
|
||||
}
|
||||
}))
|
||||
.sortBy(row => {
|
||||
return `${row.isDirectory ? 0 : 1}${row.name}`
|
||||
})
|
||||
.all(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given file path has an extension that is allowed by
|
||||
* the static server options.
|
||||
* @param filePath
|
||||
* @param options
|
||||
*/
|
||||
function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean {
|
||||
return (
|
||||
(
|
||||
!options.allowedExtensions
|
||||
|| options.allowedExtensions.includes(filePath.ext)
|
||||
)
|
||||
&& (
|
||||
!options.excludedExtensions
|
||||
|| !options.excludedExtensions.includes(filePath.ext)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the configured base path into a universal path.
|
||||
* Defaults to `{app path}/resources/static` if none provided.
|
||||
* @param appPath
|
||||
* @param basePath
|
||||
*/
|
||||
function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath {
|
||||
if ( basePath instanceof UniversalPath ) {
|
||||
return basePath
|
||||
}
|
||||
|
||||
if ( !basePath ) {
|
||||
return appPath.concat('resources', 'static')
|
||||
}
|
||||
|
||||
if ( Array.isArray(basePath) ) {
|
||||
return appPath.concat(...basePath)
|
||||
}
|
||||
|
||||
if ( basePath.startsWith('/') ) {
|
||||
return universalPath(basePath)
|
||||
}
|
||||
|
||||
return appPath.concat(basePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a route handler that serves a directory as static files.
|
||||
* @param options
|
||||
*/
|
||||
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||
return async (request: Request) => {
|
||||
const config = <Config> request.make(Config)
|
||||
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||
const app = <Application> request.make(Application)
|
||||
|
||||
const staticConfig = config.get('server.builtIns.static', {})
|
||||
const mergedOptions = {
|
||||
...staticConfig,
|
||||
...options,
|
||||
}
|
||||
|
||||
// Resolve the path to the resource on the filesystem
|
||||
const basePath = getBasePath(app.appPath(), mergedOptions.basePath)
|
||||
const filePath = basePath.concat(...Collection.normalize<string>(route.params[0]))
|
||||
|
||||
// If the resolved path is outside of the base path, fail out
|
||||
if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
reason: 'Resolved file is not a child of the base path.',
|
||||
})
|
||||
}
|
||||
|
||||
// If the resolved file is an invalid file extension, fail out
|
||||
if ( !isValidFileExtension(filePath, mergedOptions) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
allowedExtensions: mergedOptions.allowedExtensions,
|
||||
excludedExtensions: mergedOptions.excludedExtensions,
|
||||
reason: 'Resolved file is not an allowed extension type',
|
||||
})
|
||||
}
|
||||
|
||||
// If the resolved file does not exist on the filesystem, fail out
|
||||
if ( !(await filePath.exists()) ) {
|
||||
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, {
|
||||
basePath: basePath.toString(),
|
||||
filePath: filePath.toString(),
|
||||
route: route.path,
|
||||
reason: 'Resolved file does not exist on the filesystem',
|
||||
})
|
||||
}
|
||||
|
||||
// If the resolved path is a directory, send the directory listing response
|
||||
if ( await filePath.isDirectory() ) {
|
||||
if ( !route.path.endsWith('/') ) {
|
||||
return redirect(`${route.path}/`)
|
||||
}
|
||||
|
||||
return getDirectoryListingResponse(route.path, filePath)
|
||||
}
|
||||
|
||||
// Otherwise, just send the file as the response body
|
||||
return file(filePath)
|
||||
}
|
||||
}
|
||||
11
src/index.ts
11
src/index.ts
@@ -1,6 +1,12 @@
|
||||
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 './lifecycle/RunLevelErrorHandler'
|
||||
@@ -37,6 +43,7 @@ export * from './http/response/ResponseFactory'
|
||||
export * from './http/response/StringResponseFactory'
|
||||
export * from './http/response/TemporaryRedirectResponseFactory'
|
||||
export * from './http/response/ViewResponseFactory'
|
||||
export * from './http/response/FileResponseFactory'
|
||||
|
||||
export * from './http/routing/ActivatedRoute'
|
||||
export * from './http/routing/Route'
|
||||
@@ -51,6 +58,8 @@ export * from './http/session/MemorySession'
|
||||
|
||||
export * from './http/Controller'
|
||||
|
||||
export * from './http/servers/static'
|
||||
|
||||
export * from './service/Canonical'
|
||||
export * from './service/CanonicalInstantiable'
|
||||
export * from './service/CanonicalRecursive'
|
||||
@@ -65,6 +74,7 @@ export * from './service/Middlewares'
|
||||
|
||||
export * from './support/cache/MemoryCache'
|
||||
export * from './support/cache/CacheFactory'
|
||||
export * from './support/NodeModules'
|
||||
|
||||
export * from './views/ViewEngine'
|
||||
export * from './views/ViewEngineFactory'
|
||||
@@ -74,3 +84,4 @@ export * from './cli'
|
||||
export * from './i18n'
|
||||
export * from './forms'
|
||||
export * from './orm'
|
||||
export * from './auth'
|
||||
|
||||
8
src/lib.ts
Normal file
8
src/lib.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import {UniversalPath} from './util'
|
||||
|
||||
/**
|
||||
* Get the path to the root of the @extollo/lib package.
|
||||
*/
|
||||
export function lib(): UniversalPath {
|
||||
return new UniversalPath(__dirname)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Application} from './Application'
|
||||
import {Container, DependencyKey} from '../di'
|
||||
import {Container, DependencyKey, Injectable} from '../di'
|
||||
|
||||
/**
|
||||
* Base type for a class that supports binding methods by string.
|
||||
@@ -25,12 +25,11 @@ export function isBindable(what: unknown): what is Bindable {
|
||||
/**
|
||||
* Base for classes that gives access to the global application and container.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppClass {
|
||||
/** The global application instance. */
|
||||
private readonly appClassApplication!: Application;
|
||||
|
||||
constructor() {
|
||||
this.appClassApplication = Application.getApplication()
|
||||
private get appClassApplication(): Application {
|
||||
return Application.getApplication()
|
||||
}
|
||||
|
||||
/** Get the global Application. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Container} from '../di'
|
||||
import {Container, ContainerBlueprint} from '../di'
|
||||
import {
|
||||
ErrorWithContext,
|
||||
globalRegistry,
|
||||
@@ -48,10 +48,21 @@ export function appPath(...parts: PathLike[]): UniversalPath {
|
||||
* The main application container.
|
||||
*/
|
||||
export class Application extends Container {
|
||||
public static readonly NODE_MODULES_INJECTION = 'extollo/npm'
|
||||
|
||||
public static get NODE_MODULES_PROVIDER(): string {
|
||||
return process.env.EXTOLLO_NPM || 'pnpm'
|
||||
}
|
||||
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Application()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -74,6 +85,11 @@ export class Application extends Container {
|
||||
return app
|
||||
} else {
|
||||
const app = new Application()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => app.registerFactory(factory))
|
||||
|
||||
globalRegistry.setGlobal('extollo/injector', app)
|
||||
return app
|
||||
}
|
||||
@@ -195,6 +211,7 @@ export class Application extends Container {
|
||||
this.setupLogging()
|
||||
|
||||
this.registerFactory(new CacheFactory()) // FIXME move this somewhere else?
|
||||
this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER)
|
||||
|
||||
this.make<Logging>(Logging).debug(`Application root: ${this.baseDir}`)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import {ErrorWithContext} from '../../util'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Error thrown when a connection is used before it is ready.
|
||||
@@ -18,7 +21,10 @@ export class ConnectionNotReadyError extends ErrorWithContext {
|
||||
* Abstract base class for database connections.
|
||||
* @abstract
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class Connection extends AppClass {
|
||||
@Inject()
|
||||
protected bus!: EventBus
|
||||
|
||||
constructor(
|
||||
/**
|
||||
@@ -64,4 +70,14 @@ export abstract class Connection extends AppClass {
|
||||
// public abstract tables(database_name: string): Promise<Collection<Table>>
|
||||
|
||||
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
|
||||
|
||||
/**
|
||||
* Fire a QueryExecutedEvent for the given query string.
|
||||
* @param query
|
||||
* @protected
|
||||
*/
|
||||
protected async queryExecuted(query: string): Promise<void> {
|
||||
const event = new QueryExecutedEvent(this.name, this, query)
|
||||
await this.bus.dispatch(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class PostgresConnection extends Connection {
|
||||
|
||||
try {
|
||||
const result = await this.client.query(query)
|
||||
await this.queryExecuted(query)
|
||||
|
||||
return {
|
||||
rows: collect(result.rows),
|
||||
|
||||
67
src/orm/connection/event/QueryExecutedEvent.ts
Normal file
67
src/orm/connection/event/QueryExecutedEvent.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {Event} from '../../../event/Event'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {InvalidJSONStateError, JSONState} from '../../../util'
|
||||
import {Connection} from '../Connection'
|
||||
import {DatabaseService} from '../../DatabaseService'
|
||||
|
||||
/**
|
||||
* Event fired when a query is executed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class QueryExecutedEvent extends Event {
|
||||
@Inject()
|
||||
protected database!: DatabaseService
|
||||
|
||||
/**
|
||||
* The name of the connection where the query was executed.
|
||||
* @protected
|
||||
*/
|
||||
public connectionName!: string
|
||||
|
||||
/**
|
||||
* The connection where the query was executed.
|
||||
*/
|
||||
public connection!: Connection
|
||||
|
||||
/**
|
||||
* The query that was executed.
|
||||
*/
|
||||
public query!: string
|
||||
|
||||
constructor(
|
||||
connectionName?: string,
|
||||
connection?: Connection,
|
||||
query?: string,
|
||||
) {
|
||||
super()
|
||||
if ( connectionName ) {
|
||||
this.connectionName = connectionName
|
||||
}
|
||||
|
||||
if ( connection ) {
|
||||
this.connection = connection
|
||||
}
|
||||
|
||||
if ( query ) {
|
||||
this.query = query
|
||||
}
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
connectionName: this.connectionName,
|
||||
query: this.query,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): void {
|
||||
if ( !state.connectionName || !state.query ) {
|
||||
throw new InvalidJSONStateError('Missing connectionName or query from QueryExecutedEvent state.')
|
||||
}
|
||||
|
||||
this.query = String(state.query)
|
||||
this.connectionName = String(state.connectionName)
|
||||
this.connection = this.database.get(this.connectionName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from './model/Field'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelResultIterable'
|
||||
export * from './model/events'
|
||||
export * from './model/Model'
|
||||
|
||||
export * from './services/Database'
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import {ModelKey, QueryRow, QuerySource} from '../types'
|
||||
import {Container, Inject} from '../../di'
|
||||
import {Container, Inject, StaticClass} from '../../di'
|
||||
import {DatabaseService} from '../DatabaseService'
|
||||
import {ModelBuilder} from './ModelBuilder'
|
||||
import {getFieldsMeta, ModelField} from './Field'
|
||||
import {deepCopy, BehaviorSubject, Pipe, Collection} from '../../util'
|
||||
import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util'
|
||||
import {EscapeValueObject} from '../dialect/SQLDialect'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types'
|
||||
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
|
||||
import {ModelSavingEvent} from './events/ModelSavingEvent'
|
||||
import {ModelSavedEvent} from './events/ModelSavedEvent'
|
||||
import {ModelUpdatingEvent} from './events/ModelUpdatingEvent'
|
||||
import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
|
||||
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
|
||||
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
|
||||
import {EventBus} from '../../event/EventBus'
|
||||
|
||||
/**
|
||||
* Base for classes that are mapped to tables in a database.
|
||||
*/
|
||||
export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
export abstract class Model<T extends Model<T>> extends AppClass implements Bus {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
/**
|
||||
* The name of the connection this model should run through.
|
||||
@@ -78,49 +90,10 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
protected originalSourceRow?: QueryRow
|
||||
|
||||
/**
|
||||
* Behavior subject that fires after the model is populated.
|
||||
* Collection of event subscribers, by their events.
|
||||
* @protected
|
||||
*/
|
||||
protected retrieved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is saved.
|
||||
*/
|
||||
protected saving$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is saved.
|
||||
*/
|
||||
protected saved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is updated.
|
||||
*/
|
||||
protected updating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is updated.
|
||||
*/
|
||||
protected updated$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is inserted.
|
||||
*/
|
||||
protected creating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is inserted.
|
||||
*/
|
||||
protected created$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is deleted.
|
||||
*/
|
||||
protected deleting$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is deleted.
|
||||
*/
|
||||
protected deleted$ = new BehaviorSubject<Model<T>>()
|
||||
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
||||
|
||||
/**
|
||||
* Get the table name for this model.
|
||||
@@ -193,9 +166,16 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
values?: {[key: string]: any},
|
||||
) {
|
||||
super()
|
||||
this.initialize()
|
||||
this.boot(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the model is instantiated. Use for any setup of events, &c.
|
||||
* @protected
|
||||
*/
|
||||
protected initialize(): void {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Initialize the model's properties from the given values and do any other initial setup.
|
||||
*
|
||||
@@ -228,7 +208,7 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
||||
})
|
||||
|
||||
await this.retrieved$.next(this)
|
||||
await this.dispatch(new ModelRetrievedEvent<T>(this as any))
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -592,11 +572,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
* @param withoutTimestamps
|
||||
*/
|
||||
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
||||
await this.saving$.next(this)
|
||||
await this.dispatch(new ModelSavingEvent<T>(this as any))
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
if ( this.exists() && this.isDirty() ) {
|
||||
await this.updating$.next(this)
|
||||
await this.dispatch(new ModelUpdatingEvent<T>(this as any))
|
||||
|
||||
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
(this as any)[ctor.UPDATED_AT] = new Date()
|
||||
@@ -617,9 +597,9 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
await this.assumeFromSource(data)
|
||||
}
|
||||
|
||||
await this.updated$.next(this)
|
||||
await this.dispatch(new ModelUpdatedEvent<T>(this as any))
|
||||
} else if ( !this.exists() ) {
|
||||
await this.creating$.next(this)
|
||||
await this.dispatch(new ModelCreatingEvent<T>(this as any))
|
||||
|
||||
if ( !withoutTimestamps ) {
|
||||
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
||||
@@ -647,10 +627,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
if ( data ) {
|
||||
await this.assumeFromSource(result)
|
||||
}
|
||||
await this.created$.next(this)
|
||||
|
||||
await this.dispatch(new ModelCreatedEvent<T>(this as any))
|
||||
}
|
||||
|
||||
await this.saved$.next(this)
|
||||
await this.dispatch(new ModelSavedEvent<T>(this as any))
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -822,4 +803,44 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void {
|
||||
(this as any)[thisFieldName] = object[objectFieldName]
|
||||
}
|
||||
|
||||
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, EventT>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
|
||||
const entry: EventSubscriberEntry<EventT> = {
|
||||
id: uuid4(),
|
||||
event,
|
||||
subscriber,
|
||||
}
|
||||
|
||||
this.modelEventBusSubscribers.push(entry)
|
||||
return this.buildSubscription(entry.id)
|
||||
}
|
||||
|
||||
unsubscribe<EventT extends Dispatchable>(subscriber: EventSubscriber<EventT>): Awaitable<void> {
|
||||
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber)
|
||||
}
|
||||
|
||||
async dispatch(event: Dispatchable): Promise<void> {
|
||||
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
||||
await this.modelEventBusSubscribers.where('event', '=', eventClass)
|
||||
.promiseMap(entry => entry.subscriber(event))
|
||||
|
||||
await this.bus.dispatch(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.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id)
|
||||
subscribed = false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
src/orm/model/events/ModelCreatedEvent.ts
Normal file
9
src/orm/model/events/ModelCreatedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right after a model is inserted.
|
||||
*/
|
||||
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelCreatingEvent.ts
Normal file
9
src/orm/model/events/ModelCreatingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right before a model is inserted.
|
||||
*/
|
||||
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelDeletedEvent.ts
Normal file
9
src/orm/model/events/ModelDeletedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right after a model is deleted.
|
||||
*/
|
||||
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelDeletingEvent.ts
Normal file
9
src/orm/model/events/ModelDeletingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right before a model is deleted.
|
||||
*/
|
||||
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
31
src/orm/model/events/ModelEvent.ts
Normal file
31
src/orm/model/events/ModelEvent.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {Model} from '../Model'
|
||||
import {Event} from '../../../event/Event'
|
||||
import {JSONState} from '../../../util'
|
||||
|
||||
/**
|
||||
* Base class for events that concern an instance of a model.
|
||||
*/
|
||||
export abstract class ModelEvent<T extends Model<T>> extends Event {
|
||||
/**
|
||||
* The instance of the model.
|
||||
*/
|
||||
public instance!: T
|
||||
|
||||
constructor(
|
||||
instance?: T,
|
||||
) {
|
||||
super()
|
||||
if ( instance ) {
|
||||
this.instance = instance
|
||||
}
|
||||
}
|
||||
|
||||
// TODO implement serialization here
|
||||
dehydrate(): Promise<JSONState> {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
rehydrate(/* state: JSONState */): void | Promise<void> {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
9
src/orm/model/events/ModelRetrievedEvent.ts
Normal file
9
src/orm/model/events/ModelRetrievedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right after a model's data is loaded from the source.
|
||||
*/
|
||||
export class ModelRetrievedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelSavedEvent.ts
Normal file
9
src/orm/model/events/ModelSavedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right after a model is persisted to the source.
|
||||
*/
|
||||
export class ModelSavedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelSavingEvent.ts
Normal file
9
src/orm/model/events/ModelSavingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right before a model is persisted to the source.
|
||||
*/
|
||||
export class ModelSavingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelUpdatedEvent.ts
Normal file
9
src/orm/model/events/ModelUpdatedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right after a model's data is updated.
|
||||
*/
|
||||
export class ModelUpdatedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
9
src/orm/model/events/ModelUpdatingEvent.ts
Normal file
9
src/orm/model/events/ModelUpdatingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import {Model} from '../Model'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
/**
|
||||
* Event fired right before a model's data is updated.
|
||||
*/
|
||||
export class ModelUpdatingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||
|
||||
}
|
||||
21
src/orm/model/events/index.ts
Normal file
21
src/orm/model/events/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {ModelCreatedEvent} from './ModelCreatedEvent'
|
||||
import {ModelUpdatingEvent} from './ModelUpdatingEvent'
|
||||
import {ModelCreatingEvent} from './ModelCreatingEvent'
|
||||
import {ModelSavedEvent} from './ModelSavedEvent'
|
||||
import {ModelDeletedEvent} from './ModelDeletedEvent'
|
||||
import {ModelDeletingEvent} from './ModelDeletingEvent'
|
||||
import {ModelRetrievedEvent} from './ModelRetrievedEvent'
|
||||
import {ModelUpdatedEvent} from './ModelUpdatedEvent'
|
||||
import {ModelEvent} from './ModelEvent'
|
||||
|
||||
export const ModelEvents = {
|
||||
ModelCreatedEvent,
|
||||
ModelCreatingEvent,
|
||||
ModelDeletedEvent,
|
||||
ModelDeletingEvent,
|
||||
ModelEvent,
|
||||
ModelRetrievedEvent,
|
||||
ModelSavedEvent,
|
||||
ModelUpdatedEvent,
|
||||
ModelUpdatingEvent,
|
||||
}
|
||||
14
src/orm/schema/Schema.ts
Normal file
14
src/orm/schema/Schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {Awaitable} from '../../util'
|
||||
|
||||
export abstract class Schema {
|
||||
constructor(
|
||||
protected readonly connection: Connection,
|
||||
) { }
|
||||
|
||||
public abstract hasTable(name: string): Awaitable<boolean>
|
||||
|
||||
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
|
||||
|
||||
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
|
||||
}
|
||||
109
src/orm/schema/TableBuilder.ts
Normal file
109
src/orm/schema/TableBuilder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {Pipe} from '../../util'
|
||||
|
||||
export abstract class SchemaBuilderBase {
|
||||
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
|
||||
|
||||
protected shouldRenameTo?: string
|
||||
|
||||
constructor(
|
||||
protected readonly name: string,
|
||||
) { }
|
||||
|
||||
public drop(): this {
|
||||
this.shouldDrop = 'yes'
|
||||
return this
|
||||
}
|
||||
|
||||
public dropIfExists(): this {
|
||||
this.shouldDrop = 'exists'
|
||||
return this
|
||||
}
|
||||
|
||||
public rename(to: string): this {
|
||||
this.shouldRenameTo = to
|
||||
return this
|
||||
}
|
||||
|
||||
pipe(): Pipe<this> {
|
||||
return Pipe.wrap<this>(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class ColumnBuilder extends SchemaBuilderBase {
|
||||
|
||||
}
|
||||
|
||||
export class IndexBuilder extends SchemaBuilderBase {
|
||||
|
||||
protected fields: Set<string> = new Set<string>()
|
||||
|
||||
protected removedFields: Set<string> = new Set<string>()
|
||||
|
||||
protected shouldBeUnique = false
|
||||
|
||||
protected shouldBePrimary = false
|
||||
|
||||
protected field(name: string): this {
|
||||
this.fields.add(name)
|
||||
return this
|
||||
}
|
||||
|
||||
protected removeField(name: string): this {
|
||||
this.removedFields.add(name)
|
||||
this.fields.delete(name)
|
||||
return this
|
||||
}
|
||||
|
||||
primary(): this {
|
||||
this.shouldBePrimary = true
|
||||
return this
|
||||
}
|
||||
|
||||
unique(): this {
|
||||
this.shouldBeUnique = true
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class TableBuilder extends SchemaBuilderBase {
|
||||
|
||||
protected columns: {[key: string]: ColumnBuilder} = {}
|
||||
|
||||
protected indexes: {[key: string]: IndexBuilder} = {}
|
||||
|
||||
public dropColumn(name: string): this {
|
||||
this.column(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
public renameColumn(from: string, to: string): this {
|
||||
this.column(from).rename(to)
|
||||
return this
|
||||
}
|
||||
|
||||
public dropIndex(name: string): this {
|
||||
this.index(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
public renameIndex(from: string, to: string): this {
|
||||
this.index(from).rename(to)
|
||||
return this
|
||||
}
|
||||
|
||||
public column(name: string) {
|
||||
if ( !this.columns[name] ) {
|
||||
this.columns[name] = new ColumnBuilder(name)
|
||||
}
|
||||
|
||||
return this.columns[name]
|
||||
}
|
||||
|
||||
public index(name: string) {
|
||||
if ( !this.indexes[name] ) {
|
||||
this.indexes[name] = new IndexBuilder(name)
|
||||
}
|
||||
|
||||
return this.indexes[name]
|
||||
}
|
||||
}
|
||||
12
src/resources/views/auth/form.pug
Normal file
12
src/resources/views/auth/form.pug
Normal file
@@ -0,0 +1,12 @@
|
||||
extends ./theme
|
||||
|
||||
block content
|
||||
h3.login-heading.mb-4
|
||||
block heading
|
||||
|
||||
if errors
|
||||
each error in errors
|
||||
p.form-error-message #{error}
|
||||
|
||||
form(method='post' enctype='multipart/form-data')
|
||||
block form
|
||||
22
src/resources/views/auth/login.pug
Normal file
22
src/resources/views/auth/login.pug
Normal file
@@ -0,0 +1,22 @@
|
||||
extends ./form
|
||||
|
||||
block head
|
||||
title Login | #{config('app.name', 'Extollo')}
|
||||
|
||||
block heading
|
||||
| Login to Continue
|
||||
|
||||
block form
|
||||
.form-label-group
|
||||
input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus)
|
||||
label(for='inputUsername') Username
|
||||
.form-label-group
|
||||
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
|
||||
label(for='inputPassword') Password
|
||||
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
|
||||
|
||||
.text-center
|
||||
span.small Need an account?
|
||||
a(href='./register') Register here.
|
||||
// .text-center
|
||||
span.small(style="color: #999999;") Provider: #{provider_name}
|
||||
22
src/resources/views/auth/theme.pug
Normal file
22
src/resources/views/auth/theme.pug
Normal file
@@ -0,0 +1,22 @@
|
||||
html
|
||||
head
|
||||
meta(name='viewport' content='width=device-width initial-scale=1')
|
||||
|
||||
block head
|
||||
|
||||
block styles
|
||||
link(rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
|
||||
link(rel='stylesheet' href=vendor('@extollo', 'auth/theme.css'))
|
||||
body
|
||||
.container-fluid
|
||||
.row.no-gutter
|
||||
.d-none.d-md-flex.col-md-6.col-lg-8.bg-image
|
||||
.col-md-6.col-lg-4
|
||||
.login.d-flex.align-items-center.py-5
|
||||
.container
|
||||
.row
|
||||
.col-md-9.col-lg-8.mx-auto
|
||||
block content
|
||||
|
||||
block scripts
|
||||
script(src='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
|
||||
45
src/resources/views/static/dirlist.pug
Normal file
45
src/resources/views/static/dirlist.pug
Normal file
@@ -0,0 +1,45 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title Index of #{dirname}
|
||||
style.
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td, th {
|
||||
border: 1px solid #dddddd;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #dddddd;
|
||||
}
|
||||
body
|
||||
h1 Directory Listing
|
||||
h2 #{dirname}
|
||||
table
|
||||
tr
|
||||
th Name
|
||||
th Type
|
||||
th Size
|
||||
tr
|
||||
td 📂
|
||||
a(href='..') ..
|
||||
td Directory
|
||||
td -
|
||||
each entry in contents
|
||||
tr
|
||||
td #{entry.isDirectory ? '📂 ' : ''}
|
||||
a(href='./' + entry.name) #{entry.name}
|
||||
td #{entry.isDirectory ? 'Directory' : 'File'}
|
||||
td #{entry.size}
|
||||
if !config('server.poweredBy.hide', false)
|
||||
hr
|
||||
small retrieved at #{(new Date).toDateString()} #{(new Date).toTimeString()} | powered by <a href="https://extollo.garrettmills.dev/" target="_blank">Extollo</a>
|
||||
@@ -16,6 +16,7 @@ import {ExecuteResolvedRoutePreflightHTTPModule} from '../http/kernel/module/Exe
|
||||
import {ExecuteResolvedRoutePostflightHTTPModule} from '../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule'
|
||||
import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule'
|
||||
import {Config} from './Config'
|
||||
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
|
||||
|
||||
/**
|
||||
* Application unit that starts the HTTP/S server, creates Request and Response objects
|
||||
@@ -48,6 +49,7 @@ export class HTTPServer extends Unit {
|
||||
ExecuteResolvedRoutePreflightHTTPModule.register(this.kernel)
|
||||
ExecuteResolvedRoutePostflightHTTPModule.register(this.kernel)
|
||||
ParseIncomingBodyHTTPModule.register(this.kernel)
|
||||
InjectRequestEventBusHTTPModule.register(this.kernel)
|
||||
|
||||
await new Promise<void>(res => {
|
||||
this.server = createServer(this.handler)
|
||||
|
||||
@@ -7,14 +7,14 @@ import {Middleware} from '../http/routing/Middleware'
|
||||
* A canonical unit that loads the middleware classes from `app/http/middlewares`.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Middlewares extends CanonicalStatic<Instantiable<Middleware>, Middleware> {
|
||||
export class Middlewares extends CanonicalStatic<Middleware, Instantiable<Middleware>> {
|
||||
protected appPath = ['http', 'middlewares']
|
||||
|
||||
protected canonicalItem = 'middleware'
|
||||
|
||||
protected suffix = '.middleware.js'
|
||||
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Instantiable<Middleware>, Middleware>> {
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Middleware, Instantiable<Middleware>>> {
|
||||
const item = await super.initCanonicalItem(definition)
|
||||
if ( !(item.prototype instanceof Middleware) ) {
|
||||
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.http.routing.Middleware.`)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {Singleton, Inject} from '../di'
|
||||
import {UniversalPath, Collection} from '../util'
|
||||
import {UniversalPath, Collection, Pipe, universalPath} from '../util'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {Logging} from './Logging'
|
||||
import {Route} from '../http/routing/Route'
|
||||
import {HTTPMethod} from '../http/lifecycle/Request'
|
||||
import {ViewEngineFactory} from '../views/ViewEngineFactory'
|
||||
import {ViewEngine} from '../views/ViewEngine'
|
||||
import {lib} from '../lib'
|
||||
import {Config} from './Config'
|
||||
|
||||
/**
|
||||
* Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers.
|
||||
@@ -14,10 +17,16 @@ export class Routing extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
protected compiledRoutes: Collection<Route> = new Collection<Route>()
|
||||
|
||||
public async up(): Promise<void> {
|
||||
this.app().registerFactory(new ViewEngineFactory())
|
||||
const engine = <ViewEngine> this.make(ViewEngine)
|
||||
this.logging.verbose('Registering @extollo view engine namespace.')
|
||||
engine.registerNamespace('extollo', lib().concat('resources', 'views'))
|
||||
|
||||
for await ( const entry of this.path.walk() ) {
|
||||
if ( !entry.endsWith('.routes.js') ) {
|
||||
@@ -56,4 +65,62 @@ export class Routing extends Unit {
|
||||
public get path(): UniversalPath {
|
||||
return this.app().appPath('http', 'routes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collection of compiled routes.
|
||||
*/
|
||||
public getCompiled(): Collection<Route> {
|
||||
return this.compiledRoutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a UniversalPath to a file served as an asset.
|
||||
* @example
|
||||
* ```ts
|
||||
* this.getAssetPath('images', '123.jpg').toRemote // => http://localhost:8000/assets/images/123.jpg
|
||||
* ```
|
||||
* @param parts
|
||||
*/
|
||||
public getAssetPath(...parts: string[]): UniversalPath {
|
||||
return this.getAssetBase().concat(...parts)
|
||||
}
|
||||
|
||||
public getAssetBase(): UniversalPath {
|
||||
return this.getAppUrl().concat(this.config.get('server.builtIns.assets.prefix', '/assets'))
|
||||
}
|
||||
|
||||
public getVendorPath(namespace: string, ...parts: string[]): UniversalPath {
|
||||
return this.getVendorBase().concat(encodeURIComponent(namespace), ...parts)
|
||||
}
|
||||
|
||||
public getVendorBase(): UniversalPath {
|
||||
return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor'))
|
||||
}
|
||||
|
||||
public getAppUrl(): UniversalPath {
|
||||
const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase()
|
||||
const isSSL = rawHost.startsWith('https://')
|
||||
const port = this.config.get('server.port', 8000)
|
||||
|
||||
return Pipe.wrap<string>(rawHost)
|
||||
.unless(
|
||||
host => host.startsWith('http://') || host.startsWith('https'),
|
||||
host => `http://${host}`,
|
||||
)
|
||||
.when(
|
||||
host => {
|
||||
const hasPort = host.split(':').length > 2
|
||||
const defaultRaw = !isSSL && port === 80
|
||||
const defaultSSL = isSSL && port === 443
|
||||
return !hasPort && !defaultRaw && !defaultSSL
|
||||
},
|
||||
host => {
|
||||
const parts = host.split('/')
|
||||
parts[2] += `:${port}`
|
||||
return parts.join('/')
|
||||
},
|
||||
)
|
||||
.tap<UniversalPath>(host => universalPath(host))
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
112
src/support/NodeModules.ts
Normal file
112
src/support/NodeModules.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as childProcess from 'child_process'
|
||||
import {UniversalPath} from '../util'
|
||||
import {Inject, Injectable, InjectParam} from '../di'
|
||||
import {Application} from '../lifecycle/Application'
|
||||
import {Logging} from '../service/Logging'
|
||||
import {NodeModule, ExtolloAwareNodeModule} from './types'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {PackageDiscovered} from './PackageDiscovered'
|
||||
|
||||
/**
|
||||
* A helper class for discovering and interacting with
|
||||
* NPM-style modules.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NodeModules {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
constructor(
|
||||
@InjectParam(Application.NODE_MODULES_INJECTION)
|
||||
protected readonly manager: string,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Get the NodeModule entry for the base application.
|
||||
*/
|
||||
async app(): Promise<NodeModule> {
|
||||
return new Promise<NodeModule>((res, rej) => {
|
||||
childProcess.exec(`${this.manager} ls --json`, (error, stdout) => {
|
||||
if ( error ) {
|
||||
return rej(error)
|
||||
}
|
||||
|
||||
res(JSON.parse(stdout)[0])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the node_modules folder for the base application.
|
||||
*/
|
||||
async root(): Promise<UniversalPath> {
|
||||
return new Promise<UniversalPath>((res, rej) => {
|
||||
childProcess.exec(`${this.manager} root`, (error, stdout) => {
|
||||
if ( error ) {
|
||||
return rej(error)
|
||||
}
|
||||
|
||||
res(new UniversalPath(stdout.trim()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over packages, recursively, starting with the base application's
|
||||
* package.json and fire PackageDiscovered events for any that have a valid
|
||||
* Extollo discovery entry.
|
||||
*/
|
||||
async discover(): Promise<void> {
|
||||
const root = await this.root()
|
||||
const module = await this.app()
|
||||
return this.discoverRoot(root, module)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively discover child-packages from the node_modules root for the
|
||||
* given module.
|
||||
*
|
||||
* Fires PackageDiscovered events for valid, discovery-enabled packages.
|
||||
*
|
||||
* @param root - the path to node_modules
|
||||
* @param module - the module whose children we are discovering
|
||||
* @protected
|
||||
*/
|
||||
protected async discoverRoot(root: UniversalPath, module: NodeModule): Promise<void> {
|
||||
for ( const key in module.dependencies ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(module.dependencies, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.logging.verbose(`Auto-discovery considering package: ${key}`)
|
||||
|
||||
try {
|
||||
const packageJson = root.concat(key, 'package.json')
|
||||
this.logging.verbose(`Auto-discovery package path: ${packageJson}`)
|
||||
if ( await packageJson.exists() ) {
|
||||
const packageJsonString: string = await packageJson.read()
|
||||
const packageJsonData: ExtolloAwareNodeModule = JSON.parse(packageJsonString)
|
||||
if ( !packageJsonData?.extollo?.discover ) {
|
||||
this.logging.debug(`Skipping non-discoverable package: ${key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
this.logging.info(`Auto-discovering package: ${key}`)
|
||||
await this.bus.dispatch(new PackageDiscovered(packageJsonData, packageJson.clone()))
|
||||
|
||||
const packageNodeModules = packageJson.concat('..', 'node_modules')
|
||||
if ( await packageNodeModules.exists() && packageJsonData?.extollo?.recursiveDependencies?.discover ) {
|
||||
this.logging.debug(`Recursing: ${packageNodeModules}`)
|
||||
await this.discoverRoot(packageNodeModules, packageJsonData)
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.logging.error(`Encountered error while discovering package: ${key}`)
|
||||
this.logging.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/support/PackageDiscovered.ts
Normal file
33
src/support/PackageDiscovered.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Event} from '../event/Event'
|
||||
import {Awaitable, JSONState, UniversalPath} from '../util'
|
||||
import {ExtolloAwareNodeModule} from './types'
|
||||
|
||||
/**
|
||||
* An event indicating that an NPM package has been discovered
|
||||
* by the framework.
|
||||
*
|
||||
* Application services can listen for this event to register
|
||||
* various discovery logic (e.g. automatically boot units
|
||||
*/
|
||||
export class PackageDiscovered extends Event {
|
||||
constructor(
|
||||
public packageConfig: ExtolloAwareNodeModule,
|
||||
public packageJson: UniversalPath,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
dehydrate(): Awaitable<JSONState> {
|
||||
return {
|
||||
packageConfig: this.packageConfig as JSONState,
|
||||
packageJson: this.packageJson.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> {
|
||||
if ( typeof state === 'object' ) {
|
||||
this.packageConfig = (state.packageConfig as ExtolloAwareNodeModule)
|
||||
this.packageJson = new UniversalPath(String(state.packageJson))
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/support/types.ts
Normal file
45
src/support/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Partial package.json that may contain a partial Extollo discovery config.
|
||||
*/
|
||||
export interface ExtolloPackageDiscoveryConfig {
|
||||
extollo?: {
|
||||
discover?: boolean,
|
||||
units?: {
|
||||
discover?: boolean,
|
||||
paths?: string[],
|
||||
},
|
||||
recursiveDependencies?: {
|
||||
discover?: boolean,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface that defines a NodeModule dependency.
|
||||
*/
|
||||
export interface NodeDependencySpecEntry {
|
||||
from: string,
|
||||
version: string,
|
||||
resolved?: string,
|
||||
dependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
devDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
unsavedDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
optionalDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines information and dependencies of an NPM package.
|
||||
*/
|
||||
export interface NodeModule {
|
||||
name?: string,
|
||||
version?: string,
|
||||
dependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
devDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
unsavedDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
optionalDependencies?: {[key: string]: NodeDependencySpecEntry},
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for a NodeModule that contains an ExtolloPackageDiscoveryConfig.
|
||||
*/
|
||||
export type ExtolloAwareNodeModule = NodeModule & ExtolloPackageDiscoveryConfig
|
||||
@@ -50,6 +50,20 @@ class Collection<T> {
|
||||
return new Collection(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection from an item or array of items.
|
||||
* Filters out undefined items.
|
||||
* @param itemOrItems
|
||||
*/
|
||||
public static normalize<T2>(itemOrItems: (CollectionItem<T2> | undefined)[] | CollectionItem<T2> | undefined): Collection<T2> {
|
||||
if ( !Array.isArray(itemOrItems) ) {
|
||||
itemOrItems = [itemOrItems]
|
||||
}
|
||||
|
||||
const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem<T2>[]
|
||||
return new Collection<T2>(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection of "undefined" elements of a given size.
|
||||
* @param size
|
||||
|
||||
@@ -8,6 +8,11 @@ export type PipeOperator<T, T2> = (subject: T) => T2
|
||||
*/
|
||||
export type ReflexivePipeOperator<T> = (subject: T) => T
|
||||
|
||||
/**
|
||||
* A condition or condition-resolving function for pipe methods.
|
||||
*/
|
||||
export type PipeCondition<T> = boolean | ((subject: T) => boolean)
|
||||
|
||||
/**
|
||||
* A class for writing chained/conditional operations in a data-flow manner.
|
||||
*
|
||||
@@ -79,8 +84,8 @@ export class Pipe<T> {
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
when(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
if ( check ) {
|
||||
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||
return Pipe.wrap(op(this.subject))
|
||||
}
|
||||
|
||||
@@ -94,8 +99,12 @@ export class Pipe<T> {
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
unless(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
return this.when(!check, op)
|
||||
unless(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||
return this
|
||||
}
|
||||
|
||||
return Pipe.wrap(op(this.subject))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +112,7 @@ export class Pipe<T> {
|
||||
* @param check
|
||||
* @param op
|
||||
*/
|
||||
whenNot(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
whenNot(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
return this.unless(check, op)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Type representing a JSON serializable object.
|
||||
*/
|
||||
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||
import {Awaitable} from './types'
|
||||
|
||||
export type JSONState = { [key: string]: string | boolean | number | undefined | JSONState | Array<string | boolean | number | undefined | JSONState> }
|
||||
|
||||
@@ -30,14 +31,14 @@ export function isJSONState(what: unknown): what is JSONState {
|
||||
export interface Rehydratable {
|
||||
/**
|
||||
* Dehydrate this class' state and get it.
|
||||
* @return Promise<JSONState>
|
||||
* @return JSONState|Promise<JSONState>
|
||||
*/
|
||||
dehydrate(): Promise<JSONState>
|
||||
dehydrate(): Awaitable<JSONState>
|
||||
|
||||
/**
|
||||
* Rehydrate a state into this class.
|
||||
* @param {JSONState} state
|
||||
* @return void|Promise<void>
|
||||
*/
|
||||
rehydrate(state: JSONState): void | Promise<void>
|
||||
rehydrate(state: JSONState): Awaitable<void>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as nodePath from 'path'
|
||||
import * as fs from 'fs'
|
||||
import * as mkdirp from 'mkdirp'
|
||||
import { Filesystem } from './path/Filesystem'
|
||||
import ReadableStream = NodeJS.ReadableStream;
|
||||
import WritableStream = NodeJS.WritableStream;
|
||||
import * as mime from 'mime-types'
|
||||
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
||||
import {Collection} from '../collection/Collection'
|
||||
import {Readable, Writable} from 'stream'
|
||||
|
||||
/**
|
||||
* An item that could represent a path.
|
||||
@@ -22,6 +23,36 @@ export function universalPath(...parts: PathLike[]): UniversalPath {
|
||||
return main.concat(...concats)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
* @see https://stackoverflow.com/a/14919494/4971138
|
||||
*/
|
||||
export function bytesToHumanFileSize(bytes: number, si = false, dp = 1): string {
|
||||
const thresh = si ? 1000 : 1024
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B'
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
let u = -1
|
||||
const r = 10 ** dp
|
||||
|
||||
do {
|
||||
bytes /= thresh
|
||||
++u
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk recursively over entries in a directory.
|
||||
*
|
||||
@@ -155,6 +186,13 @@ export class UniversalPath {
|
||||
return `${this.prefix}${this.resourceLocalPath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the basename of the path.
|
||||
*/
|
||||
get toBase(): string {
|
||||
return nodePath.basename(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append and resolve the given paths to this resource and return a new UniversalPath.
|
||||
*
|
||||
@@ -224,6 +262,44 @@ export class UniversalPath {
|
||||
return walk(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves true if this resource is a directory.
|
||||
*/
|
||||
async isDirectory(): Promise<boolean> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
return stat.isDirectory
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fs.promises.stat(this.resourceLocalPath)).isDirectory()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves true if this resource is a regular file.
|
||||
*/
|
||||
async isFile(): Promise<boolean> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
return stat.isFile
|
||||
}
|
||||
|
||||
try {
|
||||
return (await fs.promises.stat(this.resourceLocalPath)).isFile()
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given resource exists at the path.
|
||||
*/
|
||||
@@ -244,6 +320,20 @@ export class UniversalPath {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List any immediate children of this resource.
|
||||
*/
|
||||
async list(): Promise<Collection<UniversalPath>> {
|
||||
if ( this.filesystem ) {
|
||||
const files = await this.filesystem.list(this.resourceLocalPath)
|
||||
return files.map(x => this.concat(x))
|
||||
}
|
||||
|
||||
const paths = await fs.promises.readdir(this.resourceLocalPath)
|
||||
return Collection.collect<string>(paths)
|
||||
.map(x => this.concat(x))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux.
|
||||
*/
|
||||
@@ -290,7 +380,7 @@ export class UniversalPath {
|
||||
/**
|
||||
* Get a writable stream to this file's contents.
|
||||
*/
|
||||
async writeStream(): Promise<WritableStream> {
|
||||
async writeStream(): Promise<Writable> {
|
||||
if ( this.filesystem ) {
|
||||
return this.filesystem.putStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -304,7 +394,7 @@ export class UniversalPath {
|
||||
* Read the data from this resource's file as a string.
|
||||
*/
|
||||
async read(): Promise<string> {
|
||||
let stream: ReadableStream
|
||||
let stream: Readable
|
||||
if ( this.filesystem ) {
|
||||
stream = await this.filesystem.getStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -321,10 +411,37 @@ export class UniversalPath {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of this resource in bytes.
|
||||
*/
|
||||
async sizeInBytes(): Promise<number> {
|
||||
if ( this.filesystem ) {
|
||||
const stat = await this.filesystem.stat({
|
||||
storePath: this.resourceLocalPath,
|
||||
})
|
||||
|
||||
if ( stat.exists ) {
|
||||
return stat.sizeInBytes
|
||||
}
|
||||
|
||||
throw new FileNotFoundError(this.toString())
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(this.resourceLocalPath)
|
||||
return stat.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of this resource, formatted in a human-readable string.
|
||||
*/
|
||||
async sizeForHumans(): Promise<string> {
|
||||
return bytesToHumanFileSize(await this.sizeInBytes())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a readable stream of this file's contents.
|
||||
*/
|
||||
async readStream(): Promise<ReadableStream> {
|
||||
async readStream(): Promise<Readable> {
|
||||
if ( this.filesystem ) {
|
||||
return this.filesystem.getStoreFileAsStream({
|
||||
storePath: this.resourceLocalPath,
|
||||
@@ -334,17 +451,70 @@ export class UniversalPath {
|
||||
}
|
||||
}
|
||||
|
||||
/* get mime_type() {
|
||||
return Mime.lookup(this.ext)
|
||||
/**
|
||||
* Returns true if this path exists in the subtree of the given path.
|
||||
* @param otherPath
|
||||
*/
|
||||
isChildOf(otherPath: UniversalPath): boolean {
|
||||
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
|
||||
return false
|
||||
}
|
||||
|
||||
get content_type() {
|
||||
return Mime.contentType(this.ext)
|
||||
if ( this.prefix !== otherPath.prefix ) {
|
||||
return false
|
||||
}
|
||||
|
||||
get charset() {
|
||||
if ( this.mime_type ) {
|
||||
return Mime.charset(this.mime_type)
|
||||
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
|
||||
return Boolean(relative && !relative.startsWith('..') && !nodePath.isAbsolute(relative))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given path exists in the subtree of this path.
|
||||
* @param otherPath
|
||||
*/
|
||||
isParentOf(otherPath: UniversalPath): boolean {
|
||||
return otherPath.isChildOf(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given path refers to the same resource as this path.
|
||||
* @param otherPath
|
||||
*/
|
||||
is(otherPath: UniversalPath): boolean {
|
||||
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ( this.prefix !== otherPath.prefix ) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
|
||||
return relative === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mime-type of this resource.
|
||||
*/
|
||||
get mimeType(): string | false {
|
||||
return mime.lookup(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content-type header of this resource.
|
||||
*/
|
||||
get contentType(): string | false {
|
||||
return mime.contentType(this.resourceLocalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the charset of this resource.
|
||||
*/
|
||||
get charset(): string | false {
|
||||
if ( this.mimeType ) {
|
||||
return mime.charset(this.mimeType)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import {UniversalPath} from '../path'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import {uuid4} from '../data'
|
||||
import ReadableStream = NodeJS.ReadableStream;
|
||||
import WritableStream = NodeJS.WritableStream;
|
||||
import {ErrorWithContext} from '../../error/ErrorWithContext'
|
||||
import {Readable, Writable} from 'stream'
|
||||
import {Awaitable} from '../types'
|
||||
import {Collection} from '../../collection/Collection'
|
||||
|
||||
/**
|
||||
* Error thrown when an operation is attempted on a non-existent file.
|
||||
@@ -65,6 +66,16 @@ export interface Stat {
|
||||
*/
|
||||
tags: string[],
|
||||
|
||||
/**
|
||||
* True if the resource exists as a directory.
|
||||
*/
|
||||
isDirectory: boolean,
|
||||
|
||||
/**
|
||||
* True if the resource exists as a regular file.
|
||||
*/
|
||||
isFile: boolean,
|
||||
|
||||
accessed?: Date,
|
||||
modified?: Date,
|
||||
created?: Date,
|
||||
@@ -77,12 +88,12 @@ export abstract class Filesystem {
|
||||
/**
|
||||
* Called when the Filesystem driver is initialized. Do any standup here.
|
||||
*/
|
||||
public open(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
public open(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Called when the Filesystem driver is destroyed. Do any cleanup here.
|
||||
*/
|
||||
public close(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
public close(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Get the URI prefix for this filesystem.
|
||||
@@ -114,62 +125,67 @@ export abstract class Filesystem {
|
||||
*
|
||||
* @param args
|
||||
*/
|
||||
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): void | Promise<void>
|
||||
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Download a file in the remote filesystem to the local filesystem and return it as a UniversalPath.
|
||||
* @param args
|
||||
*/
|
||||
public abstract getStoreFileAsTemp(args: {storePath: string}): UniversalPath | Promise<UniversalPath>
|
||||
public abstract getStoreFileAsTemp(args: {storePath: string}): Awaitable<UniversalPath>
|
||||
|
||||
/**
|
||||
* Open a readable stream for a file in the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract getStoreFileAsStream(args: {storePath: string}): ReadableStream | Promise<ReadableStream>
|
||||
public abstract getStoreFileAsStream(args: {storePath: string}): Awaitable<Readable>
|
||||
|
||||
/**
|
||||
* Open a writable stream for a file in the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract putStoreFileAsStream(args: {storePath: string}): WritableStream | Promise<WritableStream>
|
||||
public abstract putStoreFileAsStream(args: {storePath: string}): Awaitable<Writable>
|
||||
|
||||
/**
|
||||
* Fetch some information about a file that may or may not be in the remote filesystem without fetching the entire file.
|
||||
* @param args
|
||||
*/
|
||||
public abstract stat(args: {storePath: string}): Stat | Promise<Stat>
|
||||
public abstract stat(args: {storePath: string}): Awaitable<Stat>
|
||||
|
||||
/**
|
||||
* If the file does not exist in the remote filesystem, create it. If it does exist, update the modify timestamps.
|
||||
* @param args
|
||||
*/
|
||||
public abstract touch(args: {storePath: string}): void | Promise<void>
|
||||
public abstract touch(args: {storePath: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Remove the given resource(s) from the remote filesystem.
|
||||
* @param args
|
||||
*/
|
||||
public abstract remove(args: {storePath: string, recursive?: boolean }): void | Promise<void>
|
||||
public abstract remove(args: {storePath: string, recursive?: boolean }): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Create the given path on the store as a directory, recursively.
|
||||
* @param args
|
||||
*/
|
||||
public abstract mkdir(args: {storePath: string}): void | Promise<void>
|
||||
public abstract mkdir(args: {storePath: string}): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Get the metadata object for the given file, if it exists.
|
||||
* @param storePath
|
||||
*/
|
||||
public abstract getMetadata(storePath: string): FileMetadata | Promise<FileMetadata>
|
||||
public abstract getMetadata(storePath: string): Awaitable<FileMetadata>
|
||||
|
||||
/**
|
||||
* Set the metadata object for the given file, if the file exists.
|
||||
* @param storePath
|
||||
* @param meta
|
||||
*/
|
||||
public abstract setMetadata(storePath: string, meta: FileMetadata): void | Promise<void>
|
||||
public abstract setMetadata(storePath: string, meta: FileMetadata): Awaitable<void>
|
||||
|
||||
/**
|
||||
* List direct children of this resource.
|
||||
*/
|
||||
public abstract list(storePath: string): Awaitable<Collection<string>>
|
||||
|
||||
/**
|
||||
* Normalize the input tags into a single array of strings. This is useful for implementing the fluent
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from 'path'
|
||||
import {UniversalPath} from '../path'
|
||||
import * as rimraf from 'rimraf'
|
||||
import * as mkdirp from 'mkdirp'
|
||||
import { Collection } from '../../collection/Collection'
|
||||
|
||||
export interface LocalFilesystemConfig {
|
||||
baseDir: string
|
||||
@@ -87,6 +88,8 @@ export class LocalFilesystem extends Filesystem {
|
||||
accessed: stat.atime,
|
||||
modified: stat.mtime,
|
||||
created: stat.ctime,
|
||||
isDirectory: stat.isDirectory(),
|
||||
isFile: stat.isFile(),
|
||||
}
|
||||
} catch (e) {
|
||||
if ( e?.code === 'ENOENT' ) {
|
||||
@@ -95,6 +98,8 @@ export class LocalFilesystem extends Filesystem {
|
||||
exists: false,
|
||||
sizeInBytes: 0,
|
||||
tags: [],
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,4 +171,13 @@ export class LocalFilesystem extends Filesystem {
|
||||
protected metadataPath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* List all immediate children of the given path.
|
||||
* @param storePath
|
||||
*/
|
||||
public async list(storePath: string): Promise<Collection<string>> {
|
||||
const paths = await fs.promises.readdir(this.storePath(storePath))
|
||||
return Collection.collect<string>(paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import {FileMetadata, Filesystem, Stat} from './Filesystem'
|
||||
import * as ssh2 from 'ssh2'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import ReadableStream = NodeJS.ReadableStream
|
||||
import {Readable, Writable} from 'stream'
|
||||
import {Collection} from '../../collection/Collection'
|
||||
import {UniversalPath} from '../path'
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
})
|
||||
}
|
||||
|
||||
async getStoreFileAsStream(args: { storePath: string }): Promise<ReadableStream> {
|
||||
async getStoreFileAsStream(args: { storePath: string }): Promise<Readable> {
|
||||
const sftp = await this.getSFTP()
|
||||
return sftp.createReadStream(this.storePath(args.storePath))
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
})
|
||||
}
|
||||
|
||||
async putStoreFileAsStream(args: { storePath: string }): Promise<NodeJS.WritableStream> {
|
||||
async putStoreFileAsStream(args: { storePath: string }): Promise<Writable> {
|
||||
const sftp = await this.getSFTP()
|
||||
return sftp.createWriteStream(this.storePath(args.storePath))
|
||||
}
|
||||
@@ -126,6 +127,8 @@ export class SSHFilesystem extends Filesystem {
|
||||
accessed: stat.atime,
|
||||
modified: stat.mtime,
|
||||
created: stat.ctime,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -133,6 +136,8 @@ export class SSHFilesystem extends Filesystem {
|
||||
exists: false,
|
||||
sizeInBytes: 0,
|
||||
tags: [],
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +248,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
* @protected
|
||||
*/
|
||||
protected storePath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'data', storePath)
|
||||
return path.join(this.baseConfig.baseDir, 'data', storePath)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +257,7 @@ export class SSHFilesystem extends Filesystem {
|
||||
* @protected
|
||||
*/
|
||||
protected metadataPath(storePath: string): string {
|
||||
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
return path.join(this.baseConfig.baseDir, 'meta', storePath + '.json')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,4 +273,18 @@ export class SSHFilesystem extends Filesystem {
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
})
|
||||
}
|
||||
|
||||
async list(storePath: string): Promise<Collection<string>> {
|
||||
const sftp = await this.getSFTP()
|
||||
|
||||
return new Promise<Collection<string>>((res, rej) => {
|
||||
sftp.readdir(this.storePath(storePath), (error, files) => {
|
||||
if ( error ) {
|
||||
rej(error)
|
||||
} else {
|
||||
res(Collection.collect(files).map(x => x.filename))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
/** Type alias for something that may or may not be wrapped in a promise. */
|
||||
export type Awaitable<T> = T | Promise<T>
|
||||
|
||||
/** Type alias for something that may be undefined. */
|
||||
export type Maybe<T> = T | undefined
|
||||
|
||||
@@ -17,29 +17,36 @@ export class PugViewEngine extends ViewEngine {
|
||||
public renderByName(templateName: string, locals: { [p: string]: any }): string | Promise<string> {
|
||||
let compiled = this.compileCache[templateName]
|
||||
if ( compiled ) {
|
||||
return compiled(locals)
|
||||
return compiled({
|
||||
...this.getGlobals(),
|
||||
...locals,
|
||||
})
|
||||
}
|
||||
|
||||
if ( !templateName.endsWith('.pug') ) {
|
||||
templateName += '.pug'
|
||||
}
|
||||
const filePath = this.path.concat(...templateName.split(':'))
|
||||
compiled = pug.compileFile(filePath.toLocal, this.getOptions())
|
||||
const filePath = this.resolveName(templateName)
|
||||
compiled = pug.compileFile(filePath.toLocal, this.getOptions(templateName))
|
||||
|
||||
this.compileCache[templateName] = compiled
|
||||
return compiled(locals)
|
||||
return compiled({
|
||||
...this.getGlobals(),
|
||||
...locals,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the object of options passed to Pug's compile methods.
|
||||
* @protected
|
||||
*/
|
||||
protected getOptions(): pug.Options {
|
||||
protected getOptions(templateName?: string): pug.Options {
|
||||
return {
|
||||
basedir: this.path.toLocal,
|
||||
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
||||
debug: this.debug,
|
||||
compileDebug: this.debug,
|
||||
globals: [],
|
||||
}
|
||||
}
|
||||
|
||||
getFileExtension(): string {
|
||||
return '.pug'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
import {Config} from '../service/Config'
|
||||
import {Container} from '../di'
|
||||
import {UniversalPath} from '../util'
|
||||
import {ErrorWithContext, UniversalPath} from '../util'
|
||||
import {Routing} from '../service/Routing'
|
||||
|
||||
/**
|
||||
* Abstract base class for rendering views via different view engines.
|
||||
@@ -9,11 +10,16 @@ import {UniversalPath} from '../util'
|
||||
export abstract class ViewEngine extends AppClass {
|
||||
protected readonly config: Config
|
||||
|
||||
protected readonly routing: Routing
|
||||
|
||||
protected readonly debug: boolean
|
||||
|
||||
protected readonly namespaces: {[key: string]: UniversalPath} = {}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.config = Container.getContainer().make(Config)
|
||||
this.routing = Container.getContainer().make(Routing)
|
||||
this.debug = (this.config.get('server.mode', 'production') === 'development'
|
||||
|| this.config.get('server.debug', false))
|
||||
}
|
||||
@@ -38,4 +44,86 @@ export abstract class ViewEngine extends AppClass {
|
||||
* @param locals
|
||||
*/
|
||||
public abstract renderByName(templateName: string, locals: {[key: string]: any}): string | Promise<string>
|
||||
|
||||
/**
|
||||
* Get the file extension of template files of this engine.
|
||||
* @example `.pug`
|
||||
*/
|
||||
public abstract getFileExtension(): string
|
||||
|
||||
/**
|
||||
* Get the global variables that should be passed to every view rendered.
|
||||
* @protected
|
||||
*/
|
||||
protected getGlobals(): {[key: string]: any} {
|
||||
return {
|
||||
app: this.app(),
|
||||
config: (key: string, fallback?: any) => this.config.get(key, fallback),
|
||||
asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote,
|
||||
vendor: (namespace: string, ...parts: string[]) => this.routing.getVendorPath(namespace, ...parts).toRemote,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a path as a root for rendering views prefixed with the given namespace.
|
||||
* @param namespace
|
||||
* @param basePath
|
||||
*/
|
||||
public registerNamespace(namespace: string, basePath: UniversalPath): this {
|
||||
if ( namespace.startsWith('@') ) {
|
||||
namespace = namespace.substr(1)
|
||||
}
|
||||
|
||||
this.namespaces[namespace] = basePath
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a template, get a UniversalPath pointing to its file.
|
||||
* @param templateName
|
||||
*/
|
||||
public resolveName(templateName: string): UniversalPath {
|
||||
let path = this.path
|
||||
if ( templateName.startsWith('@') ) {
|
||||
const [namespace, ...parts] = templateName.split(':')
|
||||
path = this.namespaces[namespace.substr(1)]
|
||||
|
||||
if ( !path ) {
|
||||
throw new ErrorWithContext('Invalid template namespace: ' + namespace, {
|
||||
namespace,
|
||||
templateName,
|
||||
})
|
||||
}
|
||||
|
||||
templateName = parts.join(':')
|
||||
}
|
||||
|
||||
if ( !templateName.endsWith(this.getFileExtension()) ) {
|
||||
templateName += this.getFileExtension()
|
||||
}
|
||||
|
||||
return path.concat(...templateName.split(':'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a template, get a UniversalPath to the root of the tree where
|
||||
* that template resides.
|
||||
* @param templateName
|
||||
*/
|
||||
public resolveBasePath(templateName: string): UniversalPath {
|
||||
let path = this.path
|
||||
if ( templateName.startsWith('@') ) {
|
||||
const [namespace] = templateName.split(':')
|
||||
path = this.namespaces[namespace.substr(1)]
|
||||
|
||||
if ( !path ) {
|
||||
throw new ErrorWithContext('Invalid template namespace: ' + namespace, {
|
||||
namespace,
|
||||
templateName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user