Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c078d695a8 | |||
| 55ffadc742 | |||
| 56574d43ce | |||
| e16f02ce12 | |||
| c34fad3502 | |||
| 156006053b | |||
| 22cf6aa953 | |||
| b35eb8d6a1 | |||
| 9ee4c42e43 | |||
| 8d1dcc87fb | |||
| 3efbfecf9d | |||
| a1d04d652e | |||
| 5940b6e2b3 | |||
|
074a3187eb
|
15
.drone.yml
15
.drone.yml
@@ -22,7 +22,7 @@ steps:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
source: extollo_api_documentation.tar.gz
|
||||
target: /var/nfs/general/static/sites/extollo
|
||||
target: /var/nfs/storage/static/sites/extollo
|
||||
when:
|
||||
event: promote
|
||||
target: docs
|
||||
@@ -38,7 +38,7 @@ steps:
|
||||
from_secret: docs_deploy_key
|
||||
port: 22
|
||||
script:
|
||||
- cd /var/nfs/general/static/sites/extollo
|
||||
- cd /var/nfs/storage/static/sites/extollo
|
||||
- rm -rf docs
|
||||
- tar xzf extollo_api_documentation.tar.gz
|
||||
- rm -rf extollo_api_documentation.tar.gz
|
||||
@@ -103,10 +103,19 @@ steps:
|
||||
event:
|
||||
exclude: tag
|
||||
|
||||
- name: build module
|
||||
- name: Install dependencies
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm i
|
||||
|
||||
- name: Lint code
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm lint
|
||||
|
||||
- name: build module
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm build
|
||||
- mkdir artifacts
|
||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||
|
||||
55
.idea/codeStyles/Project.xml
generated
Normal file
55
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<editorconfig>
|
||||
<option name="ENABLED" value="false" />
|
||||
</editorconfig>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="PHP">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
<option name="SMART_TABS" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
19
.idea/dataSources.xml
generated
Normal file
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||
<driver-ref>mongo</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/lib.iml
generated
1
.idea/lib.iml
generated
@@ -4,5 +4,6 @@
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="module" module-name="extollo" />
|
||||
</component>
|
||||
</module>
|
||||
1
.idea/modules.xml
generated
1
.idea/modules.xml
generated
@@ -2,6 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.4",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -12,6 +12,7 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/ioredis": "^4.26.6",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
@@ -27,9 +28,11 @@
|
||||
"cli-table": "^0.3.6",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"ioredis": "^4.27.6",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
"node-fetch": "^3",
|
||||
"pg": "^8.6.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"pug": "^3.0.2",
|
||||
@@ -45,9 +48,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prebuild": "pnpm run lint && rimraf lib",
|
||||
"build": "tsc",
|
||||
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build",
|
||||
"docs:build": "typedoc --options typedoc.json",
|
||||
|
||||
2387
pnpm-lock.yaml
generated
2387
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {ErrorWithContext} from '../util'
|
||||
|
||||
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
private authenticatedUser?: Authenticatable
|
||||
|
||||
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
|
||||
* unauthenticated implicitly.
|
||||
* @param credentials
|
||||
*/
|
||||
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticateOnce(user)
|
||||
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
|
||||
* authentication will be persisted.
|
||||
* @param credentials
|
||||
*/
|
||||
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticate(user)
|
||||
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
const credentials = await this.getCredentials()
|
||||
this.logging.debug('resume:')
|
||||
this.logging.debug(credentials)
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
|
||||
* Get the credentials for the current user from whatever storage medium
|
||||
* the context's host provides.
|
||||
*/
|
||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
||||
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
@@ -138,6 +144,8 @@ export abstract class SecurityContext {
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
this.logging.debug('hasUser?')
|
||||
this.logging.debug(this.authenticatedUser)
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
|
||||
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Valid, ValidationError} from '../../forms'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class BasicLoginController extends Controller {
|
||||
public static routes({ enableRegistration = true } = {}): void {
|
||||
Route.group('auth', () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login')
|
||||
|
||||
Route.post('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login.attempt')
|
||||
|
||||
Route.any('logout', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogout()
|
||||
})
|
||||
.pre('@auth:required')
|
||||
.alias('@auth.logout')
|
||||
|
||||
if ( enableRegistration ) {
|
||||
Route.get('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getRegistration()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register')
|
||||
|
||||
Route.post('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptRegister()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register.attempt')
|
||||
}
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return this.getLoginView()
|
||||
}
|
||||
|
||||
public getRegistration(): ResponseFactory {
|
||||
return this.getRegistrationView()
|
||||
}
|
||||
|
||||
public async attemptLogin(): Promise<ResponseObject> {
|
||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.attempt(data)
|
||||
if ( user ) {
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
}
|
||||
|
||||
return this.getLoginView(['Invalid username/password.'])
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getLoginView(e.errors.all())
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async attemptLogout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return this.getMessageView('You have been logged out.')
|
||||
}
|
||||
|
||||
public async attemptRegister(): Promise<ResponseObject> {
|
||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.repository.createByCredentials(data)
|
||||
await this.security.authenticate(user)
|
||||
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getRegistrationView(e.errors.all())
|
||||
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
|
||||
return this.getRegistrationView(['A user with that username already exists.'])
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:login', {
|
||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:register', {
|
||||
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getMessageView(message: string): ResponseFactory {
|
||||
return view('@extollo:auth:message', {
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
|
||||
export interface BasicLoginCredentials {
|
||||
username: string,
|
||||
password: string,
|
||||
}
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
|
||||
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
username: [
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
password: [
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
|
||||
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
Str.alphaNum,
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(8),
|
||||
Str.confirmed,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||
|
||||
/**
|
||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||
@@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
||||
export interface AuthConfig {
|
||||
repositories: {
|
||||
session: keyof AuthenticatableRepositoryMapping,
|
||||
}
|
||||
},
|
||||
sources?: {
|
||||
[key: string]: OAuth2LoginConfig,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
getCredentials(): Awaitable<Record<string, string>> {
|
||||
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||
return {
|
||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
||||
identifier: '',
|
||||
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Controller} from '../../../http/Controller'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {ErrorWithContext} from '../../../util'
|
||||
import {OAuth2Repository} from './OAuth2Repository'
|
||||
import {json} from '../../../http/response/JSONResponseFactory'
|
||||
|
||||
export interface OAuth2LoginConfig {
|
||||
name: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUrl: string,
|
||||
authorizationCodeField: string,
|
||||
tokenEndpoint: string,
|
||||
tokenEndpointMapping?: {
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
grantType?: string,
|
||||
codeKey?: string,
|
||||
},
|
||||
tokenEndpointResponseMapping?: {
|
||||
token?: string,
|
||||
expiresIn?: string,
|
||||
expiresAt?: string,
|
||||
},
|
||||
userEndpoint: string,
|
||||
userEndpointResponseMapping?: {
|
||||
identifier?: string,
|
||||
display?: string,
|
||||
},
|
||||
}
|
||||
|
||||
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
|
||||
return (
|
||||
Boolean(what)
|
||||
&& typeof (what as any).name === 'string'
|
||||
&& typeof (what as any).clientId === 'string'
|
||||
&& typeof (what as any).clientSecret === 'string'
|
||||
&& typeof (what as any).redirectUrl === 'string'
|
||||
&& typeof (what as any).authorizationCodeField === 'string'
|
||||
&& typeof (what as any).tokenEndpoint === 'string'
|
||||
&& typeof (what as any).userEndpoint === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2LoginController extends Controller {
|
||||
public static routes(configName: string): void {
|
||||
Route.group(`/auth/${configName}`, () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
|
||||
return controller.getLogin()
|
||||
}).pre('@auth:guest')
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
protected readonly configName: string,
|
||||
) {
|
||||
super(request)
|
||||
}
|
||||
|
||||
public async getLogin(): Promise<ResponseObject> {
|
||||
const repo = this.getRepository()
|
||||
if ( repo.shouldRedirect() ) {
|
||||
return repo.redirect()
|
||||
}
|
||||
|
||||
// We were redirected from the auth source
|
||||
const user = await repo.redeem()
|
||||
return json(user)
|
||||
}
|
||||
|
||||
protected getRepository(): OAuth2Repository {
|
||||
return this.request.make(OAuth2Repository, this.getConfig())
|
||||
}
|
||||
|
||||
protected getConfig(): OAuth2LoginConfig {
|
||||
const config = this.config.get(`auth.sources.${this.configName}`)
|
||||
if ( !isOAuth2LoginConfig(config) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 source config.', {
|
||||
configName: this.configName,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
156
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
156
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {
|
||||
Awaitable,
|
||||
dataGetUnsafe,
|
||||
fetch,
|
||||
Maybe,
|
||||
MethodNotSupportedError,
|
||||
UniversalPath,
|
||||
universalPath,
|
||||
uuid4,
|
||||
} from '../../../util'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Session} from '../../../http/session/Session'
|
||||
import {ResponseObject} from '../../../http/routing/Route'
|
||||
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {OAuth2User} from './OAuth2User'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Repository implements AuthenticatableRepository {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor(
|
||||
protected readonly config: OAuth2LoginConfig,
|
||||
) { }
|
||||
|
||||
public createByCredentials(): Awaitable<Authenticatable> {
|
||||
throw new MethodNotSupportedError()
|
||||
}
|
||||
|
||||
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
|
||||
return this.getAuthenticatableFromBearer(credentials.credential)
|
||||
}
|
||||
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
public getRedirectUrl(state?: string): UniversalPath {
|
||||
const url = universalPath(this.config.redirectUrl)
|
||||
if ( state ) {
|
||||
url.query.append('state', state)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public getTokenEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.tokenEndpoint)
|
||||
}
|
||||
|
||||
public getUserEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.userEndpoint)
|
||||
}
|
||||
|
||||
public async redeem(): Promise<Maybe<OAuth2User>> {
|
||||
if ( !this.stateIsValid() ) {
|
||||
return // FIXME throw
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
|
||||
if ( this.config.tokenEndpointMapping ) {
|
||||
if ( this.config.tokenEndpointMapping.clientId ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.clientSecret ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.codeKey ) {
|
||||
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.grantType ) {
|
||||
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
|
||||
}
|
||||
}
|
||||
|
||||
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
|
||||
|
||||
const response = await fetch(this.getTokenEndpoint().toRemote, {
|
||||
method: 'post',
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
this.logging.debug(data)
|
||||
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
|
||||
|
||||
this.logging.debug(bearer)
|
||||
if ( !bearer || typeof bearer !== 'string' ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return this.getAuthenticatableFromBearer(bearer)
|
||||
}
|
||||
|
||||
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
|
||||
const response = await fetch(this.getUserEndpoint().toRemote, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${bearer}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return new OAuth2User(data, this.config)
|
||||
}
|
||||
|
||||
public stateIsValid(): boolean {
|
||||
const correctState = this.session.get('extollo.auth.oauth2.state', '')
|
||||
const inputState = this.request.input('state') || ''
|
||||
return correctState === inputState
|
||||
}
|
||||
|
||||
public shouldRedirect(): boolean {
|
||||
const codeField = this.config.authorizationCodeField
|
||||
const code = this.request.input(codeField)
|
||||
return !code
|
||||
}
|
||||
|
||||
public async redirect(): Promise<ResponseObject> {
|
||||
const state = uuid4()
|
||||
await this.session.set('extollo.auth.oauth2.state', state)
|
||||
return temporary(this.getRedirectUrl(state).toRemote)
|
||||
}
|
||||
}
|
||||
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
|
||||
|
||||
export class OAuth2User implements Authenticatable {
|
||||
protected displayField: string
|
||||
|
||||
protected identifierField: string
|
||||
|
||||
constructor(
|
||||
protected data: {[key: string]: any},
|
||||
config: OAuth2LoginConfig,
|
||||
) {
|
||||
this.displayField = config.userEndpointResponseMapping?.display || 'name'
|
||||
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
|
||||
}
|
||||
|
||||
getDisplayIdentifier(): string {
|
||||
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
|
||||
}
|
||||
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
isOAuth2User: true,
|
||||
data: this.data,
|
||||
displayField: this.displayField,
|
||||
identifierField: this.identifierField,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> {
|
||||
if (
|
||||
!state.isOAuth2User
|
||||
|| typeof state.data !== 'object'
|
||||
|| state.data === null
|
||||
|| typeof state.displayField !== 'string'
|
||||
|| typeof state.identifierField !== 'string'
|
||||
) {
|
||||
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
|
||||
}
|
||||
|
||||
this.data = state.data
|
||||
this.identifierField = state.identifierField
|
||||
this.displayField = state.identifierField
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,6 @@ export * from './Authentication'
|
||||
export * from './config'
|
||||
|
||||
export * from './basic-ui/BasicLoginFormRequest'
|
||||
export * from './basic-ui/BasicLoginController'
|
||||
|
||||
export * from './external/oauth2/OAuth2LoginController'
|
||||
|
||||
@@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
|
||||
/** The user's first name. */
|
||||
@Field(FieldType.varchar, 'first_name')
|
||||
public firstName!: string
|
||||
public firstName?: string
|
||||
|
||||
/** The user's last name. */
|
||||
@Field(FieldType.varchar, 'last_name')
|
||||
public lastName!: string
|
||||
public lastName?: string
|
||||
|
||||
/** The hashed and salted password of the user. */
|
||||
@Field(FieldType.varchar, 'password_hash')
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Injectable} from '../../di'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return ORMUser.query<ORMUser>()
|
||||
@@ -21,21 +30,36 @@ export class ORMUserRepository extends AuthenticatableRepository {
|
||||
* 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 ) {
|
||||
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
if ( !credentials.identifier && credentials.credential ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.securityIdentifier)
|
||||
.where('username', '=', credentials.credential)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.username && credentials.password ) {
|
||||
if ( credentials.identifier && credentials.credential ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.username)
|
||||
.where('username', '=', credentials.identifier)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
||||
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||
if ( await this.getByCredentials(credentials) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
identifier: credentials.identifier,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.make(ORMUser)
|
||||
user.username = credentials.identifier
|
||||
await user.setPassword(credentials.credential)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
export interface AuthenticatableCredentials {
|
||||
identifier: string,
|
||||
credential: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
|
||||
* Returns the user if the credentials are valid.
|
||||
* @param credentials
|
||||
*/
|
||||
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||
}
|
||||
|
||||
@@ -169,8 +169,11 @@ export abstract class Directive extends AppClass {
|
||||
const optionValues = this.parseOptions(options, argv)
|
||||
this.setOptionValues(optionValues)
|
||||
await this.handle(argv)
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.nativeOutput(e.message)
|
||||
}
|
||||
|
||||
if ( e instanceof OptionValidationError ) {
|
||||
// expecting, value, requirements
|
||||
if ( e.context.expecting ) {
|
||||
@@ -187,6 +190,7 @@ export abstract class Directive extends AppClass {
|
||||
this.nativeOutput(` - ${e.context.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.nativeOutput('\nUse --help for more info.')
|
||||
}
|
||||
}
|
||||
|
||||
23
src/cli/decorators.ts
Normal file
23
src/cli/decorators.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||
import {CommandLine} from './service'
|
||||
import {Directive} from './Directive'
|
||||
import {logIfDebugging} from '../util'
|
||||
|
||||
/**
|
||||
* Register a class as a command-line Directive.
|
||||
* The class must extend Directive.
|
||||
* @constructor
|
||||
*/
|
||||
export const CLIDirective = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiableOf(target, Directive) ) {
|
||||
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.onResolve<CommandLine>(CommandLine, cli => {
|
||||
cli.registerDirective(target as Instantiable<Directive>)
|
||||
})
|
||||
} else {
|
||||
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,5 @@ export * from './directive/options/PositionalOption'
|
||||
export * from './directive/ShellDirective'
|
||||
export * from './directive/TemplateDirective'
|
||||
export * from './directive/UsageDirective'
|
||||
|
||||
export * from './decorators'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
@@ -7,7 +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'
|
||||
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||
|
||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
@@ -17,18 +17,36 @@ export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resol
|
||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||
*/
|
||||
export class Container {
|
||||
/**
|
||||
* Given a Container instance, apply the ContainerBlueprint to it.
|
||||
* @param container
|
||||
*/
|
||||
public static realizeContainer<T extends Container>(container: T): T {
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveConstructable()
|
||||
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveResolutionCallbacks()
|
||||
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
|
||||
container.onResolve(listener.key)
|
||||
.then(value => listener.callback(value))
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global instance of this container.
|
||||
*/
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Container()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
const container = Container.realizeContainer(new Container())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -48,6 +66,12 @@ export class Container {
|
||||
*/
|
||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
||||
|
||||
/**
|
||||
* Collection of callbacks waiting for a dependency key to be resolved.
|
||||
* @protected
|
||||
*/
|
||||
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
@@ -172,6 +196,26 @@ export class Container {
|
||||
return this.instances.where('key', '=', key).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Promise that resolves the first time the given dependency key is resolved
|
||||
* by the application. If it has already been resolved, the Promise will resolve immediately.
|
||||
* @param key
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
|
||||
if ( this.hasInstance(key) ) {
|
||||
return new Promise<T>(res => res(this.make<T>(key)))
|
||||
}
|
||||
|
||||
// Otherwise, we haven't instantiated an instance with this key yet,
|
||||
// so put it onto the waitlist.
|
||||
return new Promise<T>(res => {
|
||||
this.waitingResolveCallbacks.push({
|
||||
key,
|
||||
callback: (res as (t: unknown) => unknown),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has a factory for the given key.
|
||||
* @param {DependencyKey} key
|
||||
@@ -234,6 +278,15 @@ export class Container {
|
||||
value: newInstance,
|
||||
})
|
||||
|
||||
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||
if ( waiter.key === key ) {
|
||||
waiter.callback(newInstance)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import {DependencyKey, Instantiable} from './types'
|
||||
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
|
||||
/** Simple type alias for a callback to a container's onResolve method. */
|
||||
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||
|
||||
/**
|
||||
* Blueprint for newly-created containers.
|
||||
*
|
||||
* This is used to allow global helpers like `@Singleton()`
|
||||
* or `@CLIDirective()` while still supporting multiple
|
||||
* global Container instances at once.
|
||||
*/
|
||||
export class ContainerBlueprint {
|
||||
private static instance?: ContainerBlueprint
|
||||
|
||||
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
|
||||
|
||||
protected factories: (() => AbstractFactory<any>)[] = []
|
||||
|
||||
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
|
||||
|
||||
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
|
||||
|
||||
/**
|
||||
* Register some factory class with the container. Should take no construction params.
|
||||
* @param factory
|
||||
*/
|
||||
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
|
||||
this.constructableFactories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of factory instances in the blueprint.
|
||||
*/
|
||||
resolve(): AbstractFactory<any>[] {
|
||||
return this.factories.map(x => x())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an onResolve callback to be added to all newly-created containers.
|
||||
* @param key
|
||||
* @param callback
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
|
||||
this.resolutionCallbacks.push({
|
||||
key,
|
||||
callback,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of static Factory classes that need to be instantiated by
|
||||
* the container itself.
|
||||
*/
|
||||
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
|
||||
return [...this.constructableFactories]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of DependencyKey-callback pairs to register with new containers.
|
||||
*/
|
||||
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
|
||||
return [...this.resolutionCallbacks]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'reflect-metadata'
|
||||
import {collect, Collection} from '../../util'
|
||||
import {collect, Collection, logIfDebugging} from '../../util'
|
||||
import {
|
||||
DependencyKey,
|
||||
DependencyRequirement,
|
||||
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
|
||||
* If a `key` is specified, that DependencyKey will be injected.
|
||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||
* @param key
|
||||
* @param debug
|
||||
* @constructor
|
||||
*/
|
||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
||||
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||
if ( !propertyMetadata ) {
|
||||
@@ -91,11 +92,18 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
||||
if ( existing ) {
|
||||
existing.key = key
|
||||
} else {
|
||||
propertyMetadata.push({ property,
|
||||
key })
|
||||
propertyMetadata.push({
|
||||
property,
|
||||
key,
|
||||
debug,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ( debug ) {
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
}
|
||||
@@ -152,3 +160,15 @@ export const Singleton = (name?: string): ClassDecorator => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a factory class directly with any created containers.
|
||||
* @constructor
|
||||
*/
|
||||
export const FactoryProducer = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiable(target) ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ export function isInstantiable<T>(what: unknown): what is Instantiable<T> {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given value is instantiable and, once instantiated,
|
||||
* will create an instance of the given static class.
|
||||
* @param what
|
||||
* @param type
|
||||
*/
|
||||
export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): what is Instantiable<T> {
|
||||
return isInstantiable(what) && what.prototype instanceof type
|
||||
}
|
||||
|
||||
/**
|
||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||
*/
|
||||
@@ -41,6 +51,11 @@ export function isStaticClass<T, T2>(something: unknown): something is StaticCla
|
||||
*/
|
||||
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
|
||||
|
||||
/**
|
||||
* A DependencyKey, but typed
|
||||
*/
|
||||
export type TypedDependencyKey<T> = Instantiable<T> | StaticClass<T, any> | string
|
||||
|
||||
/**
|
||||
* Interface used to store dependency requirements by their place in the injectable
|
||||
* target's parameters.
|
||||
@@ -58,6 +73,7 @@ export interface DependencyRequirement {
|
||||
export interface PropertyDependency {
|
||||
key: DependencyKey,
|
||||
property: string | symbol,
|
||||
debug?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ValidationResult, ValidatorFunction} from './types'
|
||||
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
|
||||
import {isJSON} from '../../util'
|
||||
|
||||
/**
|
||||
@@ -221,6 +221,24 @@ function lengthMax(len: number): ValidatorFunction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator function that requires the input value to match a `${field}Confirm` field's value.
|
||||
* @param fieldName
|
||||
* @param inputValue
|
||||
* @param params
|
||||
*/
|
||||
function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult {
|
||||
const confirmedFieldName = `${fieldName}Confirm`
|
||||
if ( inputValue === params.data[confirmedFieldName] ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `confirmation does not match`,
|
||||
}
|
||||
}
|
||||
|
||||
export const Str = {
|
||||
alpha,
|
||||
alphaNum,
|
||||
@@ -242,4 +260,5 @@ export const Str = {
|
||||
length,
|
||||
lengthMin,
|
||||
lengthMax,
|
||||
confirmed,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
import {Request} from './lifecycle/Request'
|
||||
import {Container} from '../di'
|
||||
import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
* Base class for controllers that define methods that
|
||||
* handle HTTP requests.
|
||||
*/
|
||||
export class Controller extends AppClass {
|
||||
export class Controller extends CanonicalItemClass {
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
) {
|
||||
|
||||
@@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
protected serverResponse: ServerResponse,
|
||||
) {
|
||||
super(Container.getContainer())
|
||||
this.registerSingletonInstance(Request, this)
|
||||
|
||||
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
||||
|
||||
@@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer {
|
||||
minor: clientRequest.httpVersionMinor,
|
||||
}
|
||||
|
||||
this.register(Request)
|
||||
this.instances.push({
|
||||
key: Request,
|
||||
value: this,
|
||||
})
|
||||
|
||||
const parts = url.parse(this.url, true)
|
||||
|
||||
this.path = parts.pathname ?? '/'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
import {Request} from '../lifecycle/Request'
|
||||
import {ResponseObject} from './Route'
|
||||
import {Container} from '../../di'
|
||||
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
* Base class representing a middleware handler that can be applied to routes.
|
||||
*/
|
||||
export abstract class Middleware extends AppClass {
|
||||
export abstract class Middleware extends CanonicalItemClass {
|
||||
constructor(
|
||||
/** The request that will be handled by this middleware. */
|
||||
protected readonly request: Request,
|
||||
|
||||
@@ -62,6 +62,9 @@ export * from './http/Controller'
|
||||
|
||||
export * from './http/servers/static'
|
||||
|
||||
export * from './support/CanonicalReceiver'
|
||||
|
||||
export * from './service/Canon'
|
||||
export * from './service/Canonical'
|
||||
export * from './service/CanonicalInstantiable'
|
||||
export * from './service/CanonicalRecursive'
|
||||
@@ -74,9 +77,14 @@ export * from './service/HTTPServer'
|
||||
export * from './service/Routing'
|
||||
export * from './service/Middlewares'
|
||||
|
||||
export * from './support/redis/Redis'
|
||||
export * from './support/cache/MemoryCache'
|
||||
export * from './support/cache/RedisCache'
|
||||
export * from './support/cache/CacheFactory'
|
||||
export * from './support/NodeModules'
|
||||
export * from './support/queue/Queue'
|
||||
|
||||
export * from './service/Queueables'
|
||||
|
||||
export * from './views/ViewEngine'
|
||||
export * from './views/ViewEngineFactory'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Container, ContainerBlueprint} from '../di'
|
||||
import {Container} from '../di'
|
||||
import {
|
||||
ErrorWithContext,
|
||||
globalRegistry,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
universalPath,
|
||||
UniversalPath,
|
||||
} from '../util'
|
||||
|
||||
import {Logging} from '../service/Logging'
|
||||
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
||||
import {Unit, UnitStatus} from './Unit'
|
||||
@@ -58,12 +57,7 @@ export class Application extends Container {
|
||||
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))
|
||||
|
||||
const container = Application.realizeContainer(new Application())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
@@ -79,18 +73,12 @@ export class Application extends Container {
|
||||
if ( existing instanceof Application ) {
|
||||
return existing
|
||||
} else if ( existing ) {
|
||||
const app = new Application()
|
||||
const app = Application.realizeContainer(new Application())
|
||||
existing.cloneTo(app)
|
||||
|
||||
globalRegistry.setGlobal('extollo/injector', app)
|
||||
return app
|
||||
} else {
|
||||
const app = new Application()
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => app.registerFactory(factory))
|
||||
|
||||
const app = Application.realizeContainer(new Application())
|
||||
globalRegistry.setGlobal('extollo/injector', app)
|
||||
return app
|
||||
}
|
||||
@@ -271,9 +259,13 @@ export class Application extends Container {
|
||||
try {
|
||||
await this.up()
|
||||
await this.down()
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.errorHandler(e)
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,10 +310,15 @@ export class Application extends Container {
|
||||
await unit.up()
|
||||
unit.status = UnitStatus.Started
|
||||
logging.info(`Started ${unit.constructor.name}.`)
|
||||
} catch (e) {
|
||||
} catch (e: unknown) {
|
||||
unit.status = UnitStatus.Error
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,7 +336,12 @@ export class Application extends Container {
|
||||
logging.info(`Stopped ${unit.constructor.name}.`)
|
||||
} catch (e) {
|
||||
unit.status = UnitStatus.Error
|
||||
|
||||
if ( e instanceof Error ) {
|
||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,13 @@ ${contextDisplay}
|
||||
}
|
||||
|
||||
this.logging.error(errorString, true)
|
||||
} catch (displayError) {
|
||||
} catch (displayError: unknown) {
|
||||
if ( displayError instanceof Error ) {
|
||||
// The error display encountered an error...
|
||||
// just throw the original so it makes it out
|
||||
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
throw operativeError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ export default class CreateUsersTableMigration extends Migration {
|
||||
|
||||
table.column('first_name')
|
||||
.type(FieldType.varchar)
|
||||
.required()
|
||||
.nullable()
|
||||
|
||||
table.column('last_name')
|
||||
.type(FieldType.varchar)
|
||||
.required()
|
||||
.nullable()
|
||||
|
||||
table.column('password_hash')
|
||||
.type(FieldType.text)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {Awaitable, ErrorWithContext} from '../../util'
|
||||
import {QueryResult} from '../types'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
import {AppClass} from '../../lifecycle/AppClass'
|
||||
@@ -68,6 +68,13 @@ export abstract class Connection extends AppClass {
|
||||
*/
|
||||
public abstract schema(name?: string): Schema
|
||||
|
||||
/**
|
||||
* Execute all queries logged to this connection during the closure
|
||||
* as a transaction in the database.
|
||||
* @param closure
|
||||
*/
|
||||
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
|
||||
|
||||
/**
|
||||
* Fire a QueryExecutedEvent for the given query string.
|
||||
* @param query
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Connection, ConnectionNotReadyError} from './Connection'
|
||||
import {Client} from 'pg'
|
||||
import {Inject} from '../../di'
|
||||
import {QueryResult} from '../types'
|
||||
import {collect} from '../../util'
|
||||
import {Awaitable, collect} from '../../util'
|
||||
import {SQLDialect} from '../dialect/SQLDialect'
|
||||
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
|
||||
import {Logging} from '../../service/Logging'
|
||||
@@ -63,11 +63,26 @@ export class PostgresConnection extends Connection {
|
||||
rowCount: result.rowCount,
|
||||
}
|
||||
} catch (e) {
|
||||
if ( e instanceof Error ) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
|
||||
if ( !this.client ) {
|
||||
throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
|
||||
}
|
||||
|
||||
await this.client.query('BEGIN')
|
||||
const result = await closure()
|
||||
await this.client.query('COMMIT')
|
||||
return result
|
||||
}
|
||||
|
||||
public schema(name?: string): Schema {
|
||||
|
||||
@@ -2,11 +2,13 @@ import {Directive, OptionDefinition} from '../../cli'
|
||||
import {Injectable} from '../../di'
|
||||
import {stringToPascal} from '../../util'
|
||||
import {templateMigration} from '../template/migration'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that creates migration classes from template.
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
export class CreateMigrationDirective extends Directive {
|
||||
getDescription(): string {
|
||||
return 'create a new migration'
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import {Directive, OptionDefinition} from '../../cli'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {EventBus} from '../../event/EventBus'
|
||||
import {Migrator} from '../migrations/Migrator'
|
||||
import {Migrations} from '../services/Migrations'
|
||||
import {Migrator} from '../migrations/Migrator'
|
||||
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||
import {EventSubscription} from '../../event/types'
|
||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that applies migrations using the default Migrator.
|
||||
* @fixme Support dry run mode
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
export class MigrateDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@@ -7,12 +7,14 @@ import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrati
|
||||
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
||||
import {EventSubscription} from '../../event/types'
|
||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||
import {CLIDirective} from '../../cli/decorators'
|
||||
|
||||
/**
|
||||
* CLI directive that undoes applied migrations using the default Migrator.
|
||||
* @fixme Support dry run mode
|
||||
*/
|
||||
@Injectable()
|
||||
@CLIDirective()
|
||||
export class RollbackDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@@ -30,19 +30,20 @@ export * from './schema/TableBuilder'
|
||||
export * from './schema/Schema'
|
||||
export * from './schema/PostgresSchema'
|
||||
|
||||
export * from './services/Migrations'
|
||||
export * from './migrations/Migrator'
|
||||
export * from './migrations/NothingToMigrateError'
|
||||
export * from './migrations/events/MigrationEvent'
|
||||
export * from './migrations/events/ApplyingMigrationEvent'
|
||||
export * from './migrations/events/AppliedMigrationEvent'
|
||||
export * from './migrations/events/RollingBackMigrationEvent'
|
||||
export * from './migrations/events/RolledBackMigrationEvent'
|
||||
export * from './migrations/Migration'
|
||||
export * from './migrations/Migrator'
|
||||
export * from './migrations/MigratorFactory'
|
||||
export * from './migrations/DatabaseMigrator'
|
||||
|
||||
export * from './services/Database'
|
||||
export * from './services/Models'
|
||||
export * from './services/Migrations'
|
||||
|
||||
export * from './directive/CreateMigrationDirective'
|
||||
export * from './directive/MigrateDirective'
|
||||
|
||||
@@ -14,7 +14,7 @@ import {NothingToMigrateError} from './NothingToMigrateError'
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class Migrator {
|
||||
@Inject()
|
||||
@Inject(Migrations, { debug: true })
|
||||
protected readonly migrations!: Migrations
|
||||
|
||||
@Inject()
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
PropertyDependency,
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||
} from '../../di'
|
||||
import {Collection, ErrorWithContext} from '../../util'
|
||||
import {Logging} from '../../service/Logging'
|
||||
@@ -17,6 +17,7 @@ import {DatabaseMigrator} from './DatabaseMigrator'
|
||||
* and produces an instance of the configured session driver implementation.
|
||||
*/
|
||||
@Injectable()
|
||||
@FactoryProducer()
|
||||
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@@ -612,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
}
|
||||
|
||||
const row = this.buildInsertFieldObject()
|
||||
this.logging.debug('Insert field object:')
|
||||
this.logging.debug(row)
|
||||
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||
|
||||
const result = await this.query()
|
||||
@@ -635,6 +637,30 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current model from the database, if it exists.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
if ( !this.exists() ) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.delete()
|
||||
|
||||
const ctor = this.constructor as typeof Model
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', ctor.key)
|
||||
|
||||
if ( field ) {
|
||||
delete (this as any)[field.modelKey]
|
||||
return
|
||||
}
|
||||
|
||||
delete (this as any)[ctor.key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast this model to a simple object mapping model fields to their values.
|
||||
*
|
||||
@@ -784,10 +810,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
||||
private buildInsertFieldObject(): EscapeValueObject {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
|
||||
|
||||
return getFieldsMeta(this)
|
||||
.pipe()
|
||||
.unless(ctor.populateKeyOnInsert, fields => {
|
||||
return fields.where('modelKey', '!=', this.keyName())
|
||||
return fields.where('databaseKey', '!=', this.keyName())
|
||||
})
|
||||
.get()
|
||||
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {Model} from './Model'
|
||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
||||
import {Instantiable} from '../../di'
|
||||
import {Instantiable, StaticClass} from '../../di'
|
||||
import {ModelResultIterable} from './ModelResultIterable'
|
||||
import {Collection} from '../../util'
|
||||
import {ConstraintOperator, ModelKey, ModelKeys} from '../types'
|
||||
import {EscapeValue} from '../dialect/SQLDialect'
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
@@ -10,7 +13,7 @@ import {ModelResultIterable} from './ModelResultIterable'
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
constructor(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: Instantiable<T>,
|
||||
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -22,4 +25,45 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
public getResultIterable(): AbstractResultIterable<T> {
|
||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE...IN... constraint on the primary key of the model.
|
||||
* @param keys
|
||||
*/
|
||||
public whereKey(keys: ModelKeys): this {
|
||||
return this.whereIn(
|
||||
this.ModelClass.qualifyKey(),
|
||||
this.normalizeModelKeys(keys),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a where constraint on the column corresponding the the specified
|
||||
* property on the model.
|
||||
* @param propertyName
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
public whereProperty(propertyName: string, operator: ConstraintOperator, operand?: EscapeValue): this {
|
||||
return this.where(
|
||||
this.ModelClass.propertyToColumn(propertyName),
|
||||
operator,
|
||||
operand,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given some format of keys of the model, try to normalize them to a flat array.
|
||||
* @param keys
|
||||
* @protected
|
||||
*/
|
||||
protected normalizeModelKeys(keys: ModelKeys): ModelKey[] {
|
||||
if ( Array.isArray(keys) ) {
|
||||
return keys
|
||||
} else if ( keys instanceof Collection ) {
|
||||
return keys.all()
|
||||
}
|
||||
|
||||
return [keys]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +164,8 @@ export class PostgresSchema extends Schema {
|
||||
.pluck<string>('column_name')
|
||||
.each(col => idx.field(col))
|
||||
})
|
||||
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
|
||||
idx.primary()
|
||||
})
|
||||
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
|
||||
idx.unique()
|
||||
})
|
||||
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())
|
||||
.when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique())
|
||||
.get()
|
||||
.flagAsExistingInSchema()
|
||||
}
|
||||
|
||||
@@ -2,22 +2,15 @@ import {Inject, Singleton} from '../../di'
|
||||
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
||||
import {Migration} from '../migrations/Migration'
|
||||
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
||||
import {Migrator} from '../migrations/Migrator'
|
||||
import {UniversalPath} from '../../util'
|
||||
import {lib} from '../../lib'
|
||||
import {CommandLine} from '../../cli'
|
||||
import {MigrateDirective} from '../directive/MigrateDirective'
|
||||
import {RollbackDirective} from '../directive/RollbackDirective'
|
||||
import {CreateMigrationDirective} from '../directive/CreateMigrationDirective'
|
||||
|
||||
/**
|
||||
* Service unit that loads and instantiates migration classes.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Migrations extends CanonicalInstantiable<Migration> {
|
||||
@Inject()
|
||||
protected readonly migrator!: Migrator
|
||||
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
@@ -38,11 +31,6 @@ export class Migrations extends CanonicalInstantiable<Migration> {
|
||||
const basePath = lib().concat('migrations')
|
||||
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
||||
this.registerNamespace('@extollo', resolver)
|
||||
|
||||
// Register the migrate CLI directives
|
||||
this.cli.registerDirective(MigrateDirective)
|
||||
this.cli.registerDirective(RollbackDirective)
|
||||
this.cli.registerDirective(CreateMigrationDirective)
|
||||
}
|
||||
|
||||
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {Model} from '../model/Model'
|
||||
import {Field} from '../model/Field'
|
||||
import {FieldType} from '../types'
|
||||
import {Maybe} from '../../util'
|
||||
import {ModelBuilder} from '../model/ModelBuilder'
|
||||
|
||||
/**
|
||||
* A model instance which stores records from the ORMCache driver.
|
||||
@@ -18,4 +20,15 @@ export class CacheModel extends Model<CacheModel> {
|
||||
|
||||
@Field(FieldType.timestamp, 'cache_expires')
|
||||
public cacheExpires?: Date;
|
||||
|
||||
public static withCacheKey(key: string): ModelBuilder<CacheModel> {
|
||||
return this.query<CacheModel>()
|
||||
.whereKey(key)
|
||||
.whereProperty('cacheExpires', '>', new Date())
|
||||
}
|
||||
|
||||
public static getCacheKey(key: string): Promise<Maybe<CacheModel>> {
|
||||
return this.withCacheKey(key)
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Container} from '../../di'
|
||||
import {Cache} from '../../util'
|
||||
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
|
||||
import {CacheModel} from './CacheModel'
|
||||
|
||||
/**
|
||||
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
|
||||
*/
|
||||
export class ORMCache extends Cache {
|
||||
public async fetch(key: string): Promise<string | undefined> {
|
||||
const model = await CacheModel.query<CacheModel>()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.first()
|
||||
|
||||
if ( model ) {
|
||||
return model.cacheValue
|
||||
}
|
||||
return (await CacheModel.getCacheKey(key))?.cacheValue
|
||||
}
|
||||
|
||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||
@@ -31,15 +24,103 @@ export class ORMCache extends Cache {
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
return CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
return CacheModel.withCacheKey(key)
|
||||
.exists()
|
||||
}
|
||||
|
||||
public async drop(key: string): Promise<void> {
|
||||
await CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.whereKey(key)
|
||||
.delete()
|
||||
}
|
||||
|
||||
public async pop(key: string): Promise<string> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<string>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
throw new ErrorWithContext('Cannot pop cache value: key does not exist.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
await model.delete()
|
||||
return model.cacheValue
|
||||
})
|
||||
}
|
||||
|
||||
public increment(key: string, amount = 1): Awaitable<number> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<number>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, String(amount))
|
||||
return amount
|
||||
}
|
||||
|
||||
model.cacheValue = String(parseInt(model.cacheValue, 10) + amount)
|
||||
await model.save()
|
||||
return parseInt(model.cacheValue, 10)
|
||||
})
|
||||
}
|
||||
|
||||
public decrement(key: string, amount = 1): Awaitable<number> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<number>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, String(-amount))
|
||||
return amount
|
||||
}
|
||||
|
||||
model.cacheValue = String(parseInt(model.cacheValue, 10) - amount)
|
||||
await model.save()
|
||||
return parseInt(model.cacheValue, 10)
|
||||
})
|
||||
}
|
||||
|
||||
public async arrayPush(key: string, value: string): Promise<void> {
|
||||
await CacheModel.getConnection()
|
||||
.asTransaction<void>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
await this.put(key, JSON.stringify([value]))
|
||||
return
|
||||
}
|
||||
|
||||
const cacheValue = JSON.parse(model.cacheValue)
|
||||
if ( !Array.isArray(cacheValue) ) {
|
||||
throw new ErrorWithContext('Cannot push value to non-array.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
cacheValue.push(value)
|
||||
model.cacheValue = JSON.stringify(cacheValue)
|
||||
})
|
||||
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
public async arrayPop(key: string): Promise<Maybe<string>> {
|
||||
return CacheModel.getConnection()
|
||||
.asTransaction<Maybe<string>>(async () => {
|
||||
const model = await CacheModel.getCacheKey(key)
|
||||
if ( !model ) {
|
||||
return
|
||||
}
|
||||
|
||||
const cacheValue = JSON.parse(model.cacheValue)
|
||||
if ( !Array.isArray(cacheValue) ) {
|
||||
throw new ErrorWithContext('Cannot pop value from non-array.', {
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
const value = cacheValue.pop()
|
||||
model.cacheValue = JSON.stringify(cacheValue)
|
||||
await model.save()
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {FieldType} from '../types'
|
||||
* Model used to fetch & store sessions from the ORMSession driver.
|
||||
*/
|
||||
export class SessionModel extends Model<SessionModel> {
|
||||
protected static table = 'sessions'; // FIXME allow configuring
|
||||
protected static table = 'sessions' // FIXME allow configuring
|
||||
|
||||
protected static key = 'session_uuid';
|
||||
protected static key = 'session_uuid'
|
||||
|
||||
protected static populateKeyOnInsert = true
|
||||
|
||||
@Field(FieldType.varchar, 'session_uuid')
|
||||
public uuid!: string;
|
||||
|
||||
@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
|
||||
*/
|
||||
export type ModelKey = string | number
|
||||
|
||||
/**
|
||||
* Collection of keys of a set of models.
|
||||
*/
|
||||
export type ModelKeys = ModelKey | ModelKey[] | Collection<ModelKey>
|
||||
|
||||
/**
|
||||
* Interface for the result of a query execution.
|
||||
*/
|
||||
|
||||
@@ -8,5 +8,9 @@ block content
|
||||
each error in errors
|
||||
p.form-error-message #{error}
|
||||
|
||||
if formAction
|
||||
form(method='post' enctype='multipart/form-data' action=formAction)
|
||||
block form
|
||||
else
|
||||
form(method='post' enctype='multipart/form-data')
|
||||
block form
|
||||
|
||||
@@ -8,11 +8,11 @@ block heading
|
||||
|
||||
block form
|
||||
.form-label-group
|
||||
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||
label(for='inputUsername') Username
|
||||
|
||||
.form-label-group
|
||||
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
|
||||
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
|
||||
label(for='inputPassword') Password
|
||||
|
||||
|
||||
@@ -21,4 +21,4 @@ block form
|
||||
|
||||
.text-center
|
||||
span.small Need an account?
|
||||
a(href='./register') Register here.
|
||||
a(href=named('@auth.register')) Register here.
|
||||
|
||||
26
src/resources/views/auth/register.pug
Normal file
26
src/resources/views/auth/register.pug
Normal file
@@ -0,0 +1,26 @@
|
||||
extends ./form
|
||||
|
||||
block head
|
||||
title Register | #{config('app.name', 'Extollo')}
|
||||
|
||||
block heading
|
||||
| Register to continue
|
||||
|
||||
block form
|
||||
.form-label-group
|
||||
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
|
||||
label(for='inputUsername') Username
|
||||
|
||||
.form-label-group
|
||||
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
|
||||
label(for='inputPassword') Password
|
||||
|
||||
.form-label-group
|
||||
input#inputPasswordConfirm.form-control(type='password' name='credentialConfirm' required placeholder='Confirm Password')
|
||||
label(for='inputPassword') Confirm 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 Have an account?
|
||||
a(href=named('@auth.login')) Login here.
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Canonical} from './Canonical'
|
||||
import {Singleton} from '../di'
|
||||
import {Maybe} from '../util'
|
||||
|
||||
/**
|
||||
* Error throw when a duplicate canonical key is registered.
|
||||
@@ -46,6 +47,17 @@ export class Canon {
|
||||
return this.resources[key] as Canonical<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a canonical item from a fully-qualified canonical name.
|
||||
* This is just a quality-of-life wrapper around `this.resource(...).get(...)`.
|
||||
* @param key
|
||||
*/
|
||||
getFromFullyQualified(key: string): Maybe<any> {
|
||||
const [namespace, ...parts] = key.split('::')
|
||||
const unqualified = parts.join('::')
|
||||
return this.resource(namespace).get(unqualified)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a canonical resource.
|
||||
* @param {Canonical} unit
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Logging} from './Logging'
|
||||
import {Inject} from '../di'
|
||||
import * as nodePath from 'path'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {isCanonicalReceiver} from '../support/CanonicalReceiver'
|
||||
|
||||
/**
|
||||
* Interface describing a definition of a single canonical item loaded from the app.
|
||||
@@ -228,7 +229,16 @@ export abstract class Canonical<T> extends Unit {
|
||||
|
||||
const definition = await this.buildCanonicalDefinition(entry)
|
||||
this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`)
|
||||
this.loadedItems[definition.canonicalName] = await this.initCanonicalItem(definition)
|
||||
const resolvedItem = await this.initCanonicalItem(definition)
|
||||
|
||||
if ( isCanonicalReceiver(resolvedItem) ) {
|
||||
resolvedItem.setCanonicalResolver(
|
||||
`${this.canonicalItems}::${definition.canonicalName}`,
|
||||
definition.canonicalName,
|
||||
)
|
||||
}
|
||||
|
||||
this.loadedItems[definition.canonicalName] = resolvedItem
|
||||
}
|
||||
|
||||
this.canon.registerCanonical(this)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Inject, Singleton} from '../di'
|
||||
import {HTTPStatus, withTimeout} from '../util'
|
||||
import {ErrorWithContext, HTTPStatus, withTimeout} from '../util'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
|
||||
import {Logging} from './Logging'
|
||||
@@ -82,7 +82,8 @@ export class HTTPServer extends Unit {
|
||||
}
|
||||
|
||||
public get handler(): RequestListener {
|
||||
const timeout = this.config.get('server.timeout', 10000)
|
||||
// const timeout = this.config.get('server.timeout', 10000)
|
||||
const timeout = 0 // temporarily disable this because it is causing problems
|
||||
|
||||
return async (request: IncomingMessage, response: ServerResponse) => {
|
||||
const extolloReq = new Request(request, response)
|
||||
@@ -113,9 +114,13 @@ export class HTTPServer extends Unit {
|
||||
try {
|
||||
await this.kernel.handle(extolloReq)
|
||||
} catch (e) {
|
||||
if ( e instanceof Error ) {
|
||||
await error(e).write(extolloReq)
|
||||
}
|
||||
|
||||
await error(new ErrorWithContext('Unknown error occurred.', { e }))
|
||||
}
|
||||
|
||||
await extolloReq.response.send()
|
||||
}
|
||||
}
|
||||
|
||||
25
src/service/Queueables.ts
Normal file
25
src/service/Queueables.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {CanonicalStatic} from './CanonicalStatic'
|
||||
import {Singleton, Instantiable, StaticClass} from '../di'
|
||||
import {CanonicalDefinition} from './Canonical'
|
||||
import {Queueable} from '../support/queue/Queue'
|
||||
|
||||
/**
|
||||
* A canonical unit that resolves Queueable classes from `app/queueables`.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Queueables extends CanonicalStatic<Queueable, Instantiable<Queueable>> {
|
||||
protected appPath = ['queueables']
|
||||
|
||||
protected canonicalItem = 'job'
|
||||
|
||||
protected suffix = '.job.js'
|
||||
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Queueable, Instantiable<Queueable>>> {
|
||||
const item = await super.initCanonicalItem(definition)
|
||||
if ( !(item.prototype instanceof Queueable) ) {
|
||||
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.Queueable.`)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
61
src/support/CanonicalReceiver.ts
Normal file
61
src/support/CanonicalReceiver.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
|
||||
/**
|
||||
* Interface for a class that receives its canonical resolver names upon load.
|
||||
*/
|
||||
export interface CanonicalReceiver {
|
||||
setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void
|
||||
getCanonicalResolver(): string | undefined
|
||||
getFullyQualifiedCanonicalResolver(): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that checks whether a given value satisfies the CanonicalReceiver interface.
|
||||
* @param something
|
||||
*/
|
||||
export function isCanonicalReceiver(something: unknown): something is CanonicalReceiver {
|
||||
return (
|
||||
typeof something === 'function'
|
||||
&& typeof (something as any).setCanonicalResolver === 'function'
|
||||
&& (something as any).setCanonicalResolver.length >= 1
|
||||
&& typeof (something as any).getCanonicalResolver === 'function'
|
||||
&& (something as any).getCanonicalResolver.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for canonical items that implements the CanonicalReceiver interface.
|
||||
* That is, `isCanonicalReceiver(CanonicalItemClass) === true`.
|
||||
*/
|
||||
export class CanonicalItemClass extends AppClass {
|
||||
/** The type-prefixed canonical resolver of this class, set by the startup unit. */
|
||||
private static canonFullyQualifiedResolver?: string
|
||||
|
||||
/** The unqualified canonical resolver of this class, set by the startup unit. */
|
||||
private static canonUnqualifiedResolver?: string
|
||||
|
||||
/**
|
||||
* Sets the fully- and un-qualified canonical resolver strings. Intended for use
|
||||
* by the Canonical unit.
|
||||
* @param fullyQualifiedResolver
|
||||
* @param unqualifiedResolver
|
||||
*/
|
||||
public static setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void {
|
||||
this.canonFullyQualifiedResolver = fullyQualifiedResolver
|
||||
this.canonUnqualifiedResolver = unqualifiedResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fully-qualified canonical resolver of this class, if one has been set.
|
||||
*/
|
||||
public static getFullyQualifiedCanonicalResolver(): string | undefined {
|
||||
return this.canonFullyQualifiedResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unqualified canonical resolver of this class, if one has been set.
|
||||
*/
|
||||
public static getCanonicalResolver(): string | undefined {
|
||||
return this.canonUnqualifiedResolver
|
||||
}
|
||||
}
|
||||
42
src/support/cache/MemoryCache.ts
vendored
42
src/support/cache/MemoryCache.ts
vendored
@@ -8,7 +8,10 @@ export class MemoryCache extends Cache {
|
||||
/** Static collection of in-memory cache items. */
|
||||
private static cacheItems: Collection<{key: string, value: string, expires?: Date}> = new Collection<{key: string; value: string, expires?: Date}>()
|
||||
|
||||
public fetch(key: string): Awaitable<string|undefined> {
|
||||
/** Static collection of in-memory arrays. */
|
||||
private static cacheArrays: Collection<{key: string, values: string[]}> = new Collection<{key: string; values: string[]}>()
|
||||
|
||||
public fetch(key: string): string|undefined {
|
||||
const now = new Date()
|
||||
return MemoryCache.cacheItems
|
||||
.where('key', '=', key)
|
||||
@@ -41,4 +44,41 @@ export class MemoryCache extends Cache {
|
||||
public drop(key: string): Awaitable<void> {
|
||||
MemoryCache.cacheItems = MemoryCache.cacheItems.where('key', '!=', key)
|
||||
}
|
||||
|
||||
public decrement(key: string, amount = 1): Awaitable<number | undefined> {
|
||||
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) - amount
|
||||
this.put(key, String(nextValue))
|
||||
return nextValue
|
||||
}
|
||||
|
||||
public increment(key: string, amount = 1): Awaitable<number | undefined> {
|
||||
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) + amount
|
||||
this.put(key, String(nextValue))
|
||||
return nextValue
|
||||
}
|
||||
|
||||
public pop(key: string): Awaitable<string | undefined> {
|
||||
const value = this.fetch(key)
|
||||
this.drop(key)
|
||||
return value
|
||||
}
|
||||
|
||||
public arrayPop(key: string): Awaitable<string | undefined> {
|
||||
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
|
||||
if ( arr ) {
|
||||
return arr.values.shift()
|
||||
}
|
||||
}
|
||||
|
||||
public arrayPush(key: string, value: string): Awaitable<void> {
|
||||
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
|
||||
if ( arr ) {
|
||||
arr.values.push(value)
|
||||
} else {
|
||||
MemoryCache.cacheArrays.push({
|
||||
key,
|
||||
values: [value],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
src/support/cache/RedisCache.ts
vendored
Normal file
85
src/support/cache/RedisCache.ts
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
import {Cache, Maybe} from '../../util'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Redis} from '../redis/Redis'
|
||||
|
||||
/**
|
||||
* Redis-driven Cache implementation.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisCache extends Cache {
|
||||
/** The Redis service. */
|
||||
@Inject()
|
||||
protected readonly redis!: Redis
|
||||
|
||||
async arrayPop(key: string): Promise<string | undefined> {
|
||||
return this.redis.pipe()
|
||||
.tap(redis => redis.lpop(key))
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async arrayPush(key: string, value: string): Promise<void> {
|
||||
await this.redis.pipe()
|
||||
.tap(redis => redis.rpush(key, value))
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async decrement(key: string, amount?: number): Promise<number | undefined> {
|
||||
return this.redis.pipe()
|
||||
.tap(redis => redis.decrby(key, amount ?? 1))
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async increment(key: string, amount?: number): Promise<number | undefined> {
|
||||
return this.redis.pipe()
|
||||
.tap(redis => redis.incrby(key, amount ?? 1))
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async drop(key: string): Promise<void> {
|
||||
await this.redis.pipe()
|
||||
.tap(redis => redis.del(key))
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async fetch(key: string): Promise<string | undefined> {
|
||||
return this.redis.pipe()
|
||||
.tap(redis => redis.get(key))
|
||||
.tap(value => value ?? undefined)
|
||||
.resolve()
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.redis.pipe()
|
||||
.tap(redis => redis.exists(key))
|
||||
.tap(numExisting => numExisting > 0)
|
||||
.resolve()
|
||||
}
|
||||
|
||||
pop(key: string): Promise<Maybe<string>> {
|
||||
return new Promise<Maybe<string>>((res, rej) => {
|
||||
this.redis.pipe()
|
||||
.tap(redis => {
|
||||
redis.multi()
|
||||
.get(key, (err, value) => {
|
||||
if ( err ) {
|
||||
rej(err)
|
||||
} else {
|
||||
res(value)
|
||||
}
|
||||
})
|
||||
.del(key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||
await this.redis.multi()
|
||||
.tap(redis => redis.set(key, value))
|
||||
.when(Boolean(expires), redis => {
|
||||
const seconds = Math.round(((new Date()).getTime() - expires!.getTime()) / 1000) // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
return redis.expire(key, seconds)
|
||||
})
|
||||
.tap(pipeline => pipeline.exec())
|
||||
.resolve()
|
||||
}
|
||||
}
|
||||
190
src/support/queue/Queue.ts
Normal file
190
src/support/queue/Queue.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import {Awaitable, ErrorWithContext, JSONState, Maybe, Rehydratable, Cache} from '../../util'
|
||||
import {CanonicalItemClass} from '../CanonicalReceiver'
|
||||
import {Container, Inject, Injectable, isInstantiable} from '../../di'
|
||||
import {Canon} from '../../service/Canon'
|
||||
|
||||
/** Type annotation for a Queueable that should be pushed onto a queue. */
|
||||
export type ShouldQueue<T> = T & Queueable
|
||||
|
||||
/**
|
||||
* Base class for an object that can be pushed to/popped from a queue.
|
||||
*/
|
||||
export abstract class Queueable extends CanonicalItemClass implements Rehydratable {
|
||||
abstract dehydrate(): Awaitable<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||
|
||||
/**
|
||||
* When the item is popped from the queue, this method is called.
|
||||
*/
|
||||
public abstract execute(): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Determine whether the object should be pushed to the queue or not.
|
||||
*/
|
||||
public shouldQueue(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the queue where this object should be pushed by default.
|
||||
*/
|
||||
public defaultQueue(): string {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canonical resolver so we can re-instantiate this class from the queue.
|
||||
* Throw an error if it could not be determined.
|
||||
*/
|
||||
public getFullyQualifiedCanonicalResolver(): string {
|
||||
const resolver = (this.constructor as typeof Queueable).getFullyQualifiedCanonicalResolver()
|
||||
if ( !resolver ) {
|
||||
throw new ErrorWithContext('Cannot push Queueable onto queue: missing canonical resolver.')
|
||||
}
|
||||
|
||||
return resolver
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truth function that returns true if an object implements the same interface as Queueable.
|
||||
* This is done in case some external library needs to be incorporated as the base class for
|
||||
* a Queueable, and cannot be made to extend Queueable.
|
||||
* @param something
|
||||
*/
|
||||
export function isQueueable(something: unknown): something is Queueable {
|
||||
if ( something instanceof Queueable ) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
typeof something === 'function'
|
||||
&& typeof (something as any).dehydrate === 'function'
|
||||
&& typeof (something as any).rehydrate === 'function'
|
||||
&& typeof (something as any).shouldQueue === 'function'
|
||||
&& typeof (something as any).defaultQueue === 'function'
|
||||
&& typeof (something as any).getFullyQualifiedCanonicalResolver === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Truth function that returns true if the given object is Queueable and wants to be
|
||||
* pushed onto the queue.
|
||||
* @param something
|
||||
*/
|
||||
export function shouldQueue<T>(something: T): something is ShouldQueue<T> {
|
||||
return isQueueable(something) && something.shouldQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-node queue that accepts & reinstantiates Queueables.
|
||||
*
|
||||
* @example
|
||||
* There are several queue backends your application may use. These are
|
||||
* configured via the `queue` config. To get the default queue, however,
|
||||
* use this class as a DI token:
|
||||
* ```ts
|
||||
* this.container().make<Queue>(Queue)
|
||||
* ```
|
||||
*
|
||||
* This will resolve the concrete implementation configured by your app.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Queue {
|
||||
@Inject()
|
||||
protected readonly cache!: Cache
|
||||
|
||||
@Inject()
|
||||
protected readonly canon!: Canon
|
||||
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
) { }
|
||||
|
||||
public get queueIdentifier(): string {
|
||||
return `extollo__queue__${this.name}`
|
||||
}
|
||||
|
||||
/** Get the number of items waiting in the queue. */
|
||||
// public abstract length(): Awaitable<number>
|
||||
|
||||
/** Push a new queueable onto the queue. */
|
||||
public async push(item: ShouldQueue<Queueable>): Promise<void> {
|
||||
const data = {
|
||||
q: true,
|
||||
r: item.getFullyQualifiedCanonicalResolver(),
|
||||
d: await item.dehydrate(),
|
||||
}
|
||||
|
||||
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(data))
|
||||
}
|
||||
|
||||
/** Remove and return a queueable from the queue. */
|
||||
public async pop(): Promise<Maybe<Queueable>> {
|
||||
const item = await this.cache.arrayPop(this.queueIdentifier)
|
||||
if ( !item ) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(item)
|
||||
if ( !data.q || !data.r ) {
|
||||
throw new ErrorWithContext('Cannot pop Queueable: payload is invalid.', {
|
||||
data,
|
||||
queueName: this.name,
|
||||
queueIdentifier: this.queueIdentifier,
|
||||
})
|
||||
}
|
||||
|
||||
const canonicalItem = this.canon.getFromFullyQualified(data.r)
|
||||
if ( !canonicalItem ) {
|
||||
throw new ErrorWithContext('Cannot pop Queueable: canonical name is not resolvable', {
|
||||
data,
|
||||
queueName: this.name,
|
||||
queueIdentifier: this.queueIdentifier,
|
||||
canonicalName: data.r,
|
||||
})
|
||||
}
|
||||
|
||||
if ( !isInstantiable(canonicalItem) ) {
|
||||
throw new ErrorWithContext('Cannot pop Queueable: canonical item is not instantiable', {
|
||||
data,
|
||||
canonicalItem,
|
||||
queueName: this.name,
|
||||
queueIdentifier: this.queueIdentifier,
|
||||
canonicalName: data.r,
|
||||
})
|
||||
}
|
||||
|
||||
const instance = this.injector.make(canonicalItem)
|
||||
if ( !isQueueable(instance) ) {
|
||||
throw new ErrorWithContext('Cannot pop Queueable: canonical item instance is not Queueable', {
|
||||
data,
|
||||
canonicalItem,
|
||||
instance,
|
||||
queueName: this.name,
|
||||
queueIdentifier: this.queueIdentifier,
|
||||
canonicalName: data.r,
|
||||
})
|
||||
}
|
||||
|
||||
await instance.rehydrate(data.d)
|
||||
return instance
|
||||
}
|
||||
|
||||
/** Push a raw payload onto the queue. */
|
||||
public async pushRaw(item: JSONState): Promise<void> {
|
||||
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(item))
|
||||
}
|
||||
|
||||
/** Remove and return a raw payload from the queue. */
|
||||
public async popRaw(): Promise<Maybe<JSONState>> {
|
||||
const item = await this.cache.arrayPop(this.queueIdentifier)
|
||||
if ( item ) {
|
||||
return JSON.parse(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/support/redis/Redis.ts
Normal file
75
src/support/redis/Redis.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {Inject, Singleton} from '../../di'
|
||||
import {Config} from '../../service/Config'
|
||||
import * as IORedis from 'ioredis'
|
||||
import {RedisOptions} from 'ioredis'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
import {AsyncPipe} from '../../util'
|
||||
|
||||
export {RedisOptions} from 'ioredis'
|
||||
|
||||
/**
|
||||
* Unit that loads configuration for and manages instantiation
|
||||
* of an IORedis connection.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Redis extends Unit {
|
||||
/** The config service. */
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
/** The loggers. */
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/**
|
||||
* The instantiated connection, if one exists.
|
||||
* @private
|
||||
*/
|
||||
private connection?: IORedis.Redis
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.logging.info('Attempting initial connection to Redis...')
|
||||
this.logging.debug('Config:')
|
||||
this.logging.debug(Config)
|
||||
this.logging.debug(this.config)
|
||||
await this.getConnection()
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.logging.info('Disconnecting Redis...')
|
||||
if ( this.connection?.status === 'ready' ) {
|
||||
await this.connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IORedis connection instance.
|
||||
*/
|
||||
public async getConnection(): Promise<IORedis.Redis> {
|
||||
if ( !this.connection ) {
|
||||
const options = this.config.get('redis.connection') as RedisOptions
|
||||
this.logging.verbose(options)
|
||||
this.connection = new IORedis(options)
|
||||
}
|
||||
|
||||
return this.connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IORedis connection in an AsyncPipe.
|
||||
*/
|
||||
public pipe(): AsyncPipe<IORedis.Redis> {
|
||||
return new AsyncPipe<IORedis.Redis>(() => this.getConnection())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an IORedis.Pipeline instance in an AsyncPipe.
|
||||
*/
|
||||
public multi(): AsyncPipe<IORedis.Pipeline> {
|
||||
return this.pipe()
|
||||
.tap(redis => {
|
||||
return redis.multi()
|
||||
})
|
||||
}
|
||||
}
|
||||
37
src/util/cache/Cache.ts
vendored
37
src/util/cache/Cache.ts
vendored
@@ -15,8 +15,9 @@ export abstract class Cache {
|
||||
* Store the given value in the cache by key.
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @param expires
|
||||
*/
|
||||
public abstract put(key: string, value: string): Awaitable<void>;
|
||||
public abstract put(key: string, value: string, expires?: Date): Awaitable<void>;
|
||||
|
||||
/**
|
||||
* Check if the cache has the given key.
|
||||
@@ -30,4 +31,38 @@ export abstract class Cache {
|
||||
* @param {string} key
|
||||
*/
|
||||
public abstract drop(key: string): Awaitable<void>;
|
||||
|
||||
/**
|
||||
* Fetch an item from the cache by key, and then remove it.
|
||||
* @param key
|
||||
*/
|
||||
public abstract pop(key: string): Awaitable<string|undefined>;
|
||||
|
||||
/**
|
||||
* Increment a key in the cache by a given amount.
|
||||
* @param key
|
||||
* @param amount
|
||||
*/
|
||||
public abstract increment(key: string, amount?: number): Awaitable<number|undefined>;
|
||||
|
||||
/**
|
||||
* Decrement a key in the cache by a given amount.
|
||||
* @param key
|
||||
* @param amount
|
||||
*/
|
||||
public abstract decrement(key: string, amount?: number): Awaitable<number|undefined>;
|
||||
|
||||
/**
|
||||
* Push an item onto the end an array-like key.
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
public abstract arrayPush(key: string, value: string): Awaitable<void>;
|
||||
|
||||
/**
|
||||
* Remove and return an item from the beginning of an array-like key.
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
public abstract arrayPop(key: string): Awaitable<string|undefined>;
|
||||
}
|
||||
|
||||
59
src/util/cache/InMemCache.ts
vendored
59
src/util/cache/InMemCache.ts
vendored
@@ -1,5 +1,7 @@
|
||||
import { Cache } from './Cache'
|
||||
import { Collection } from '../collection/Collection'
|
||||
import {Awaitable, Maybe} from '../support/types'
|
||||
import {ErrorWithContext} from '../error/ErrorWithContext'
|
||||
|
||||
/**
|
||||
* Base interface for an item stored in a memory cache.
|
||||
@@ -44,4 +46,61 @@ export class InMemCache extends Cache {
|
||||
public async drop(key: string): Promise<void> {
|
||||
this.items = this.items.whereNot('key', '=', key)
|
||||
}
|
||||
|
||||
public pop(key: string): Awaitable<Maybe<string>> {
|
||||
const existing = this.items.firstWhere('key', '=', key)
|
||||
this.items = this.items.where('key', '!=', key)
|
||||
return existing?.item
|
||||
}
|
||||
|
||||
public async increment(key: string, amount?: number): Promise<number> {
|
||||
const next = parseInt((await this.fetch(key)) ?? '0', 10) + (amount ?? 1)
|
||||
await this.put(key, String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
public async decrement(key: string, amount?: number): Promise<number> {
|
||||
const next = parseInt((await this.fetch(key)) ?? '0', 10) - (amount ?? 1)
|
||||
await this.put(key, String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
public arrayPush(key: string, value: string): Awaitable<void> {
|
||||
const existing = this.items.where('key', '=', key).first()
|
||||
const arr = JSON.parse(existing?.item ?? '[]')
|
||||
|
||||
if ( !Array.isArray(arr) ) {
|
||||
throw new ErrorWithContext('Unable to arrayPush: key is not an array', {
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
arr.push(value)
|
||||
if ( existing ) {
|
||||
existing.item = JSON.stringify(arr)
|
||||
} else {
|
||||
this.items.push({
|
||||
key,
|
||||
item: JSON.stringify(arr),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public arrayPop(key: string): Awaitable<Maybe<string>> {
|
||||
const existing = this.items.where('key', '=', key).first()
|
||||
const arr = JSON.parse(existing?.item ?? '[]')
|
||||
|
||||
const value = arr.pop()
|
||||
if ( existing ) {
|
||||
existing.item = JSON.stringify(arr)
|
||||
} else {
|
||||
this.items.push({
|
||||
key,
|
||||
item: JSON.stringify(arr),
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
10
src/util/error/MethodNotSupportedError.ts
Normal file
10
src/util/error/MethodNotSupportedError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import {ErrorWithContext} from './ErrorWithContext'
|
||||
|
||||
export class MethodNotSupportedError extends ErrorWithContext {
|
||||
constructor(
|
||||
message = 'Method not supported',
|
||||
context: {[key: string]: any} = {},
|
||||
) {
|
||||
super(message, context)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import {RequestInfo, RequestInit, Response} from 'node-fetch'
|
||||
import {unsafeESMImport} from './unsafe'
|
||||
export const fetch = (url: RequestInfo, init?: RequestInit): Promise<Response> => unsafeESMImport('node-fetch').then(({default: nodeFetch}) => nodeFetch(url, init))
|
||||
|
||||
export * from './cache/Cache'
|
||||
export * from './cache/InMemCache'
|
||||
|
||||
@@ -10,6 +14,7 @@ export * from './collection/where'
|
||||
export * from './const/http'
|
||||
|
||||
export * from './error/ErrorWithContext'
|
||||
export * from './error/MethodNotSupportedError'
|
||||
|
||||
export * from './logging/Logger'
|
||||
export * from './logging/StandardLogger'
|
||||
|
||||
@@ -137,7 +137,7 @@ export class BehaviorSubject<T> {
|
||||
} catch (e) {
|
||||
if ( e instanceof UnsubscribeError ) {
|
||||
this.subscribers = this.subscribers.filter(x => x !== subscriber)
|
||||
} else if (subscriber.error) {
|
||||
} else if (subscriber.error && e instanceof Error) {
|
||||
await subscriber.error(e)
|
||||
} else {
|
||||
throw e
|
||||
@@ -181,7 +181,7 @@ export class BehaviorSubject<T> {
|
||||
try {
|
||||
await subscriber.complete(finalValue)
|
||||
} catch (e) {
|
||||
if ( subscriber.error ) {
|
||||
if ( subscriber.error && e instanceof Error ) {
|
||||
await subscriber.error(e)
|
||||
} else {
|
||||
throw e
|
||||
|
||||
@@ -8,7 +8,7 @@ export type PipeOperator<T, T2> = (subject: T) => T2
|
||||
/**
|
||||
* A closure that maps a given pipe item to an item of the same type.
|
||||
*/
|
||||
export type ReflexivePipeOperator<T> = (subject: T) => T|void
|
||||
export type ReflexivePipeOperator<T> = (subject: T) => T
|
||||
|
||||
/**
|
||||
* A condition or condition-resolving function for pipe methods.
|
||||
@@ -97,7 +97,7 @@ export class Pipe<T> {
|
||||
*/
|
||||
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||
Pipe.wrap(op(this.subject))
|
||||
return Pipe.wrap(op(this.subject))
|
||||
}
|
||||
|
||||
return this
|
||||
@@ -115,8 +115,7 @@ export class Pipe<T> {
|
||||
return this
|
||||
}
|
||||
|
||||
Pipe.wrap(op(this.subject))
|
||||
return this
|
||||
return Pipe.wrap(op(this.subject))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +157,8 @@ export type AsyncPipeResolver<T> = () => Awaitable<T>
|
||||
*/
|
||||
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
|
||||
|
||||
export type PromisePipeOperator<T, T2> = (subject: T, resolve: (val: T2) => unknown, reject: (err: Error) => unknown) => Awaitable<unknown>
|
||||
|
||||
/**
|
||||
* A closure that maps a given pipe item to an item of the same type.
|
||||
*/
|
||||
@@ -193,6 +194,23 @@ export class AsyncPipe<T> {
|
||||
return new AsyncPipe<T2>(async () => op(await this.subject()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a transformative operator to the pipe, wrapping it
|
||||
* in a Promise and passing the resolve/reject callbacks to the
|
||||
* closure.
|
||||
* @param op
|
||||
*/
|
||||
promise<T2>(op: PromisePipeOperator<T, T2>): AsyncPipe<T2> {
|
||||
return new AsyncPipe<T2>(() => {
|
||||
return new Promise<T2>((res, rej) => {
|
||||
(async () => this.subject())()
|
||||
.then(subject => {
|
||||
op(subject, res, rej)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an operator to the pipe, but return the reference
|
||||
* to the current pipe. The operator is resolved when the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export function isDebugging(key: string): boolean {
|
||||
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
|
||||
.toUpperCase()
|
||||
|
||||
return process.env[env] === 'yes'
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as mime from 'mime-types'
|
||||
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
||||
import {Collection} from '../collection/Collection'
|
||||
import {Readable, Writable} from 'stream'
|
||||
import {Pipe} from './Pipe'
|
||||
|
||||
/**
|
||||
* An item that could represent a path.
|
||||
@@ -82,6 +83,8 @@ export class UniversalPath {
|
||||
|
||||
protected resourceLocalPath!: string
|
||||
|
||||
protected resourceQuery: URLSearchParams = new URLSearchParams()
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The path string this path refers to.
|
||||
@@ -94,6 +97,10 @@ export class UniversalPath {
|
||||
) {
|
||||
this.setPrefix()
|
||||
this.setLocal()
|
||||
|
||||
if ( this.isRemote ) {
|
||||
this.resourceQuery = (new URL(this.toRemote)).searchParams
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +147,13 @@ export class UniversalPath {
|
||||
return new UniversalPath(this.initial)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URLSearchParams for this resource.
|
||||
*/
|
||||
get query(): URLSearchParams {
|
||||
return this.resourceQuery
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string of this resource.
|
||||
*/
|
||||
@@ -183,7 +197,8 @@ export class UniversalPath {
|
||||
* Get the fully-prefixed path to this resource.
|
||||
*/
|
||||
get toRemote(): string {
|
||||
return `${this.prefix}${this.resourceLocalPath}`
|
||||
const query = this.query.toString()
|
||||
return `${this.prefix}${this.resourceLocalPath}${query ? '?' + query : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -517,4 +532,9 @@ export class UniversalPath {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Get a new Pipe instance wrapping this. */
|
||||
toPipe(): Pipe<UniversalPath> {
|
||||
return Pipe.wrap(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class LocalFilesystem extends Filesystem {
|
||||
isFile: stat.isFile(),
|
||||
}
|
||||
} catch (e) {
|
||||
if ( e?.code === 'ENOENT' ) {
|
||||
if ( (e as any)?.code === 'ENOENT' ) {
|
||||
return {
|
||||
path: new UniversalPath(args.storePath, this),
|
||||
exists: false,
|
||||
|
||||
@@ -52,12 +52,14 @@ export function withTimeout<T>(timeout: number, promise: Promise<T>): TimeoutSub
|
||||
run: async () => {
|
||||
let expired = false
|
||||
let resolved = false
|
||||
if ( timeout ) {
|
||||
setTimeout(() => {
|
||||
expired = true
|
||||
if ( !resolved ) {
|
||||
timeoutHandler()
|
||||
}
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
const result: T = await promise
|
||||
resolved = true
|
||||
|
||||
24
src/util/unsafe.ts
Normal file
24
src/util/unsafe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* UNSAFE
|
||||
*
|
||||
* Sometimes, we need to make a literal `import()` call from within commonJS
|
||||
* modules in order to pull in ES modules from commonJS.
|
||||
*
|
||||
* However, when tsc renders the modules to commonJS, it rewrites _all_ calls
|
||||
* to `import` as calls to `require`, which means we cannot actually use ES
|
||||
* modules from commonJS-transpiled TypeScript.
|
||||
*
|
||||
* To bypass this, we can eval the literal string. This is a stupid hack and
|
||||
* I hate it so much, but unfortunately it works.
|
||||
*
|
||||
* So, this is a wrapper function that results in a call to the literal
|
||||
* `import(...)` function in the transpiled code. It should be used VERY
|
||||
* sparingly.
|
||||
*
|
||||
* @see https://github.com/microsoft/TypeScript/issues/43329
|
||||
* @param path
|
||||
*/
|
||||
export function unsafeESMImport(path: string): Promise<any> {
|
||||
((p: string) => p)(path)
|
||||
return eval('import(path)') // eslint-disable-line no-eval
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export class PugViewEngine extends ViewEngine {
|
||||
return {
|
||||
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
||||
debug: this.debug,
|
||||
compileDebug: this.debug,
|
||||
// compileDebug: this.debug,
|
||||
globals: [],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user