Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 248b24e612 | |||
| b4a9057e2b | |||
| 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
|
from_secret: docs_deploy_key
|
||||||
port: 22
|
port: 22
|
||||||
source: extollo_api_documentation.tar.gz
|
source: extollo_api_documentation.tar.gz
|
||||||
target: /var/nfs/general/static/sites/extollo
|
target: /var/nfs/storage/static/sites/extollo
|
||||||
when:
|
when:
|
||||||
event: promote
|
event: promote
|
||||||
target: docs
|
target: docs
|
||||||
@@ -38,7 +38,7 @@ steps:
|
|||||||
from_secret: docs_deploy_key
|
from_secret: docs_deploy_key
|
||||||
port: 22
|
port: 22
|
||||||
script:
|
script:
|
||||||
- cd /var/nfs/general/static/sites/extollo
|
- cd /var/nfs/storage/static/sites/extollo
|
||||||
- rm -rf docs
|
- rm -rf docs
|
||||||
- tar xzf extollo_api_documentation.tar.gz
|
- tar xzf extollo_api_documentation.tar.gz
|
||||||
- rm -rf extollo_api_documentation.tar.gz
|
- rm -rf extollo_api_documentation.tar.gz
|
||||||
@@ -103,10 +103,19 @@ steps:
|
|||||||
event:
|
event:
|
||||||
exclude: tag
|
exclude: tag
|
||||||
|
|
||||||
- name: build module
|
- name: Install dependencies
|
||||||
image: glmdev/node-pnpm:latest
|
image: glmdev/node-pnpm:latest
|
||||||
commands:
|
commands:
|
||||||
- pnpm i
|
- pnpm i
|
||||||
|
|
||||||
|
- name: Lint code
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
|
- pnpm lint
|
||||||
|
|
||||||
|
- name: build module
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
- pnpm build
|
- pnpm build
|
||||||
- mkdir artifacts
|
- mkdir artifacts
|
||||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
- 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$" />
|
<content url="file://$MODULE_DIR$" />
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="module" module-name="extollo" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
1
.idea/modules.xml
generated
1
.idea/modules.xml
generated
@@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<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" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@extollo/lib",
|
"name": "@extollo/lib",
|
||||||
"version": "0.5.0",
|
"version": "0.5.5",
|
||||||
"description": "The framework library that lifts up your code.",
|
"description": "The framework library that lifts up your code.",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/busboy": "^0.2.3",
|
"@types/busboy": "^0.2.3",
|
||||||
"@types/cli-table": "^0.3.0",
|
"@types/cli-table": "^0.3.0",
|
||||||
|
"@types/ioredis": "^4.26.6",
|
||||||
"@types/mime-types": "^2.1.0",
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/mkdirp": "^1.0.1",
|
"@types/mkdirp": "^1.0.1",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
@@ -27,9 +28,11 @@
|
|||||||
"cli-table": "^0.3.6",
|
"cli-table": "^0.3.6",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
|
"ioredis": "^4.27.6",
|
||||||
"mime-types": "^2.1.31",
|
"mime-types": "^2.1.31",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"negotiator": "^0.6.2",
|
"negotiator": "^0.6.2",
|
||||||
|
"node-fetch": "^3",
|
||||||
"pg": "^8.6.0",
|
"pg": "^8.6.0",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
@@ -45,9 +48,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"prebuild": "pnpm run lint && rimraf lib",
|
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||||
"build": "tsc",
|
|
||||||
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
|
||||||
"app": "tsc && node lib/index.js",
|
"app": "tsc && node lib/index.js",
|
||||||
"prepare": "pnpm run build",
|
"prepare": "pnpm run build",
|
||||||
"docs:build": "typedoc --options typedoc.json",
|
"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 {Inject, Injectable} from '../di'
|
||||||
import {EventBus} from '../event/EventBus'
|
import {EventBus} from '../event/EventBus'
|
||||||
import {Awaitable, Maybe} from '../util'
|
import {Awaitable, Maybe} from '../util'
|
||||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base-class for a context that authenticates users and manages security.
|
* Base-class for a context that authenticates users and manages security.
|
||||||
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
|
|||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
/** The currently authenticated user, if one exists. */
|
/** The currently authenticated user, if one exists. */
|
||||||
private authenticatedUser?: Authenticatable
|
private authenticatedUser?: Authenticatable
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
|
|||||||
* unauthenticated implicitly.
|
* unauthenticated implicitly.
|
||||||
* @param credentials
|
* @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)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
await this.authenticateOnce(user)
|
await this.authenticateOnce(user)
|
||||||
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
|
|||||||
* authentication will be persisted.
|
* authentication will be persisted.
|
||||||
* @param credentials
|
* @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)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
await this.authenticate(user)
|
await this.authenticate(user)
|
||||||
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
|
|||||||
*/
|
*/
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
const credentials = await this.getCredentials()
|
const credentials = await this.getCredentials()
|
||||||
|
this.logging.debug('resume:')
|
||||||
|
this.logging.debug(credentials)
|
||||||
const user = await this.repository.getByCredentials(credentials)
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
if ( user ) {
|
if ( user ) {
|
||||||
this.authenticatedUser = user
|
this.authenticatedUser = user
|
||||||
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
|
|||||||
* Get the credentials for the current user from whatever storage medium
|
* Get the credentials for the current user from whatever storage medium
|
||||||
* the context's host provides.
|
* the context's host provides.
|
||||||
*/
|
*/
|
||||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently authenticated user, if one exists.
|
* 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.
|
* Returns true if there is a currently authenticated user.
|
||||||
*/
|
*/
|
||||||
hasUser(): boolean {
|
hasUser(): boolean {
|
||||||
|
this.logging.debug('hasUser?')
|
||||||
|
this.logging.debug(this.authenticatedUser)
|
||||||
return Boolean(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 {FormRequest, ValidationRules} from '../../forms'
|
||||||
import {Is, Str} from '../../forms/rules/rules'
|
import {Is, Str} from '../../forms/rules/rules'
|
||||||
import {Singleton} from '../../di'
|
import {Singleton} from '../../di'
|
||||||
|
import {AuthenticatableCredentials} from '../types'
|
||||||
export interface BasicLoginCredentials {
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
|
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||||
protected getRules(): ValidationRules {
|
protected getRules(): ValidationRules {
|
||||||
return {
|
return {
|
||||||
username: [
|
identifier: [
|
||||||
Is.required,
|
Is.required,
|
||||||
Str.lengthMin(1),
|
Str.lengthMin(1),
|
||||||
],
|
],
|
||||||
password: [
|
credential: [
|
||||||
Is.required,
|
Is.required,
|
||||||
Str.lengthMin(1),
|
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 {Instantiable} from '../di'
|
||||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||||
|
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||||
@@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
|||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
repositories: {
|
repositories: {
|
||||||
session: keyof AuthenticatableRepositoryMapping,
|
session: keyof AuthenticatableRepositoryMapping,
|
||||||
}
|
},
|
||||||
|
sources?: {
|
||||||
|
[key: string]: OAuth2LoginConfig,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
|
|||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {Session} from '../../http/session/Session'
|
import {Session} from '../../http/session/Session'
|
||||||
import {Awaitable} from '../../util'
|
import {Awaitable} from '../../util'
|
||||||
import {AuthenticatableRepository} from '../types'
|
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security context implementation that uses the session as storage.
|
* Security context implementation that uses the session as storage.
|
||||||
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
|
|||||||
super(repository, 'session')
|
super(repository, 'session')
|
||||||
}
|
}
|
||||||
|
|
||||||
getCredentials(): Awaitable<Record<string, string>> {
|
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||||
return {
|
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 './config'
|
||||||
|
|
||||||
export * from './basic-ui/BasicLoginFormRequest'
|
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. */
|
/** The user's first name. */
|
||||||
@Field(FieldType.varchar, 'first_name')
|
@Field(FieldType.varchar, 'first_name')
|
||||||
public firstName!: string
|
public firstName?: string
|
||||||
|
|
||||||
/** The user's last name. */
|
/** The user's last name. */
|
||||||
@Field(FieldType.varchar, 'last_name')
|
@Field(FieldType.varchar, 'last_name')
|
||||||
public lastName!: string
|
public lastName?: string
|
||||||
|
|
||||||
/** The hashed and salted password of the user. */
|
/** The hashed and salted password of the user. */
|
||||||
@Field(FieldType.varchar, 'password_hash')
|
@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 {Awaitable, Maybe} from '../../util'
|
||||||
import {ORMUser} from './ORMUser'
|
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.
|
* A user repository implementation that looks up users stored in the database.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ORMUserRepository extends AuthenticatableRepository {
|
export class ORMUserRepository extends AuthenticatableRepository {
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
/** Look up the user by their username. */
|
/** Look up the user by their username. */
|
||||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||||
return ORMUser.query<ORMUser>()
|
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.
|
* If username/password are specified, look up the user and verify the password.
|
||||||
* @param credentials
|
* @param credentials
|
||||||
*/
|
*/
|
||||||
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||||
if ( credentials.securityIdentifier ) {
|
if ( !credentials.identifier && credentials.credential ) {
|
||||||
return ORMUser.query<ORMUser>()
|
return ORMUser.query<ORMUser>()
|
||||||
.where('username', '=', credentials.securityIdentifier)
|
.where('username', '=', credentials.credential)
|
||||||
.first()
|
.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( credentials.username && credentials.password ) {
|
if ( credentials.identifier && credentials.credential ) {
|
||||||
const user = await ORMUser.query<ORMUser>()
|
const user = await ORMUser.query<ORMUser>()
|
||||||
.where('username', '=', credentials.username)
|
.where('username', '=', credentials.identifier)
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||||
return user
|
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. */
|
/** Value that can be used to uniquely identify a user. */
|
||||||
export type AuthenticatableIdentifier = string | number
|
export type AuthenticatableIdentifier = string | number
|
||||||
|
|
||||||
|
export interface AuthenticatableCredentials {
|
||||||
|
identifier: string,
|
||||||
|
credential: string,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for entities that can be authenticated.
|
* Base class for entities that can be authenticated.
|
||||||
*/
|
*/
|
||||||
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
|
|||||||
* Returns the user if the credentials are valid.
|
* Returns the user if the credentials are valid.
|
||||||
* @param credentials
|
* @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,12 @@ export abstract class Directive extends AppClass {
|
|||||||
const optionValues = this.parseOptions(options, argv)
|
const optionValues = this.parseOptions(options, argv)
|
||||||
this.setOptionValues(optionValues)
|
this.setOptionValues(optionValues)
|
||||||
await this.handle(argv)
|
await this.handle(argv)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
this.nativeOutput(e.message)
|
if ( e instanceof Error ) {
|
||||||
|
this.nativeOutput(e.message)
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
if ( e instanceof OptionValidationError ) {
|
if ( e instanceof OptionValidationError ) {
|
||||||
// expecting, value, requirements
|
// expecting, value, requirements
|
||||||
if ( e.context.expecting ) {
|
if ( e.context.expecting ) {
|
||||||
@@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
|
|||||||
this.nativeOutput(` - ${e.context.value}`)
|
this.nativeOutput(` - ${e.context.value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nativeOutput('\nUse --help for more info.')
|
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/ShellDirective'
|
||||||
export * from './directive/TemplateDirective'
|
export * from './directive/TemplateDirective'
|
||||||
export * from './directive/UsageDirective'
|
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 {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
@@ -7,7 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
|
|||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import SingletonFactory from './factory/SingletonFactory'
|
import SingletonFactory from './factory/SingletonFactory'
|
||||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||||
import {ContainerBlueprint} from './ContainerBlueprint'
|
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||||
|
|
||||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||||
export type MaybeDependency = any | 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.
|
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||||
*/
|
*/
|
||||||
export class Container {
|
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.
|
* Get the global instance of this container.
|
||||||
*/
|
*/
|
||||||
public static getContainer(): Container {
|
public static getContainer(): Container {
|
||||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||||
if ( !existing ) {
|
if ( !existing ) {
|
||||||
const container = new Container()
|
const container = Container.realizeContainer(new Container())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => container.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -48,6 +66,12 @@ export class Container {
|
|||||||
*/
|
*/
|
||||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
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() {
|
constructor() {
|
||||||
this.registerSingletonInstance<Container>(Container, this)
|
this.registerSingletonInstance<Container>(Container, this)
|
||||||
this.registerSingleton('injector', this)
|
this.registerSingleton('injector', this)
|
||||||
@@ -172,6 +196,26 @@ export class Container {
|
|||||||
return this.instances.where('key', '=', key).isNotEmpty()
|
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.
|
* Returns true if the container has a factory for the given key.
|
||||||
* @param {DependencyKey} key
|
* @param {DependencyKey} key
|
||||||
@@ -234,6 +278,15 @@ export class Container {
|
|||||||
value: newInstance,
|
value: newInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||||
|
if ( waiter.key === key ) {
|
||||||
|
waiter.callback(newInstance)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return newInstance
|
return newInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import {DependencyKey, Instantiable} from './types'
|
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import {AbstractFactory} from './factory/AbstractFactory'
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
import {ClosureFactory} from './factory/ClosureFactory'
|
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 {
|
export class ContainerBlueprint {
|
||||||
private static instance?: ContainerBlueprint
|
private static instance?: ContainerBlueprint
|
||||||
|
|
||||||
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
|
|||||||
|
|
||||||
protected factories: (() => AbstractFactory<any>)[] = []
|
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,
|
* Register a basic instantiable class as a standard Factory with this container,
|
||||||
* identified by a string name rather than static class.
|
* identified by a string name rather than static class.
|
||||||
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of factory instances in the blueprint.
|
||||||
|
*/
|
||||||
resolve(): AbstractFactory<any>[] {
|
resolve(): AbstractFactory<any>[] {
|
||||||
return this.factories.map(x => x())
|
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 'reflect-metadata'
|
||||||
import {collect, Collection} from '../../util'
|
import {collect, Collection, logIfDebugging} from '../../util'
|
||||||
import {
|
import {
|
||||||
DependencyKey,
|
DependencyKey,
|
||||||
DependencyRequirement,
|
DependencyRequirement,
|
||||||
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
|
|||||||
* If a `key` is specified, that DependencyKey will be injected.
|
* If a `key` is specified, that DependencyKey will be injected.
|
||||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||||
* @param key
|
* @param key
|
||||||
|
* @param debug
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||||
return (target, property) => {
|
return (target, property) => {
|
||||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||||
if ( !propertyMetadata ) {
|
if ( !propertyMetadata ) {
|
||||||
@@ -91,11 +92,18 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
|||||||
if ( existing ) {
|
if ( existing ) {
|
||||||
existing.key = key
|
existing.key = key
|
||||||
} else {
|
} else {
|
||||||
propertyMetadata.push({ property,
|
propertyMetadata.push({
|
||||||
key })
|
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)
|
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.
|
* 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
|
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
|
* Interface used to store dependency requirements by their place in the injectable
|
||||||
* target's parameters.
|
* target's parameters.
|
||||||
@@ -58,6 +73,7 @@ export interface DependencyRequirement {
|
|||||||
export interface PropertyDependency {
|
export interface PropertyDependency {
|
||||||
key: DependencyKey,
|
key: DependencyKey,
|
||||||
property: string | symbol,
|
property: string | symbol,
|
||||||
|
debug?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ValidationResult, ValidatorFunction} from './types'
|
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
|
||||||
import {isJSON} from '../../util'
|
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 = {
|
export const Str = {
|
||||||
alpha,
|
alpha,
|
||||||
alphaNum,
|
alphaNum,
|
||||||
@@ -242,4 +260,5 @@ export const Str = {
|
|||||||
length,
|
length,
|
||||||
lengthMin,
|
lengthMin,
|
||||||
lengthMax,
|
lengthMax,
|
||||||
|
confirmed,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {AppClass} from '../lifecycle/AppClass'
|
|
||||||
import {Request} from './lifecycle/Request'
|
import {Request} from './lifecycle/Request'
|
||||||
import {Container} from '../di'
|
import {Container} from '../di'
|
||||||
|
import {CanonicalItemClass} from '../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for controllers that define methods that
|
* Base class for controllers that define methods that
|
||||||
* handle HTTP requests.
|
* handle HTTP requests.
|
||||||
*/
|
*/
|
||||||
export class Controller extends AppClass {
|
export class Controller extends CanonicalItemClass {
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly request: Request,
|
protected readonly request: Request,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer {
|
|||||||
protected serverResponse: ServerResponse,
|
protected serverResponse: ServerResponse,
|
||||||
) {
|
) {
|
||||||
super(Container.getContainer())
|
super(Container.getContainer())
|
||||||
|
this.registerSingletonInstance(Request, this)
|
||||||
|
|
||||||
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
|
||||||
|
|
||||||
@@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer {
|
|||||||
minor: clientRequest.httpVersionMinor,
|
minor: clientRequest.httpVersionMinor,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.register(Request)
|
|
||||||
this.instances.push({
|
|
||||||
key: Request,
|
|
||||||
value: this,
|
|
||||||
})
|
|
||||||
|
|
||||||
const parts = url.parse(this.url, true)
|
const parts = url.parse(this.url, true)
|
||||||
|
|
||||||
this.path = parts.pathname ?? '/'
|
this.path = parts.pathname ?? '/'
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {AppClass} from '../../lifecycle/AppClass'
|
|
||||||
import {Request} from '../lifecycle/Request'
|
import {Request} from '../lifecycle/Request'
|
||||||
import {ResponseObject} from './Route'
|
import {ResponseObject} from './Route'
|
||||||
import {Container} from '../../di'
|
import {Container} from '../../di'
|
||||||
|
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class representing a middleware handler that can be applied to routes.
|
* 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(
|
constructor(
|
||||||
/** The request that will be handled by this middleware. */
|
/** The request that will be handled by this middleware. */
|
||||||
protected readonly request: Request,
|
protected readonly request: Request,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export * from './http/Controller'
|
|||||||
|
|
||||||
export * from './http/servers/static'
|
export * from './http/servers/static'
|
||||||
|
|
||||||
|
export * from './support/CanonicalReceiver'
|
||||||
|
|
||||||
|
export * from './service/Canon'
|
||||||
export * from './service/Canonical'
|
export * from './service/Canonical'
|
||||||
export * from './service/CanonicalInstantiable'
|
export * from './service/CanonicalInstantiable'
|
||||||
export * from './service/CanonicalRecursive'
|
export * from './service/CanonicalRecursive'
|
||||||
@@ -74,9 +77,14 @@ export * from './service/HTTPServer'
|
|||||||
export * from './service/Routing'
|
export * from './service/Routing'
|
||||||
export * from './service/Middlewares'
|
export * from './service/Middlewares'
|
||||||
|
|
||||||
|
export * from './support/redis/Redis'
|
||||||
export * from './support/cache/MemoryCache'
|
export * from './support/cache/MemoryCache'
|
||||||
|
export * from './support/cache/RedisCache'
|
||||||
export * from './support/cache/CacheFactory'
|
export * from './support/cache/CacheFactory'
|
||||||
export * from './support/NodeModules'
|
export * from './support/NodeModules'
|
||||||
|
export * from './support/queue/Queue'
|
||||||
|
|
||||||
|
export * from './service/Queueables'
|
||||||
|
|
||||||
export * from './views/ViewEngine'
|
export * from './views/ViewEngine'
|
||||||
export * from './views/ViewEngineFactory'
|
export * from './views/ViewEngineFactory'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Container, ContainerBlueprint} from '../di'
|
import {Container} from '../di'
|
||||||
import {
|
import {
|
||||||
ErrorWithContext,
|
ErrorWithContext,
|
||||||
globalRegistry,
|
globalRegistry,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
universalPath,
|
universalPath,
|
||||||
UniversalPath,
|
UniversalPath,
|
||||||
} from '../util'
|
} from '../util'
|
||||||
|
|
||||||
import {Logging} from '../service/Logging'
|
import {Logging} from '../service/Logging'
|
||||||
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
|
||||||
import {Unit, UnitStatus} from './Unit'
|
import {Unit, UnitStatus} from './Unit'
|
||||||
@@ -58,12 +57,7 @@ export class Application extends Container {
|
|||||||
public static getContainer(): Container {
|
public static getContainer(): Container {
|
||||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||||
if ( !existing ) {
|
if ( !existing ) {
|
||||||
const container = new Application()
|
const container = Application.realizeContainer(new Application())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => container.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -79,18 +73,12 @@ export class Application extends Container {
|
|||||||
if ( existing instanceof Application ) {
|
if ( existing instanceof Application ) {
|
||||||
return existing
|
return existing
|
||||||
} else if ( existing ) {
|
} else if ( existing ) {
|
||||||
const app = new Application()
|
const app = Application.realizeContainer(new Application())
|
||||||
existing.cloneTo(app)
|
existing.cloneTo(app)
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', app)
|
globalRegistry.setGlobal('extollo/injector', app)
|
||||||
return app
|
return app
|
||||||
} else {
|
} else {
|
||||||
const app = new Application()
|
const app = Application.realizeContainer(new Application())
|
||||||
|
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
|
||||||
.resolve()
|
|
||||||
.map(factory => app.registerFactory(factory))
|
|
||||||
|
|
||||||
globalRegistry.setGlobal('extollo/injector', app)
|
globalRegistry.setGlobal('extollo/injector', app)
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
@@ -271,8 +259,12 @@ export class Application extends Container {
|
|||||||
try {
|
try {
|
||||||
await this.up()
|
await this.up()
|
||||||
await this.down()
|
await this.down()
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
this.errorHandler(e)
|
if ( e instanceof Error ) {
|
||||||
|
this.errorHandler(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +310,14 @@ export class Application extends Container {
|
|||||||
await unit.up()
|
await unit.up()
|
||||||
unit.status = UnitStatus.Started
|
unit.status = UnitStatus.Started
|
||||||
logging.info(`Started ${unit.constructor.name}.`)
|
logging.info(`Started ${unit.constructor.name}.`)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
unit.status = UnitStatus.Error
|
unit.status = UnitStatus.Error
|
||||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
|
||||||
|
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}.`)
|
logging.info(`Stopped ${unit.constructor.name}.`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
unit.status = UnitStatus.Error
|
unit.status = UnitStatus.Error
|
||||||
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
|
||||||
|
if ( e instanceof Error ) {
|
||||||
|
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,13 @@ ${contextDisplay}
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logging.error(errorString, true)
|
this.logging.error(errorString, true)
|
||||||
} catch (displayError) {
|
} catch (displayError: unknown) {
|
||||||
// The error display encountered an error...
|
if ( displayError instanceof Error ) {
|
||||||
// just throw the original so it makes it out
|
// The error display encountered an error...
|
||||||
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
// just throw the original so it makes it out
|
||||||
|
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
|
||||||
throw operativeError
|
throw operativeError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export default class CreateUsersTableMigration extends Migration {
|
|||||||
|
|
||||||
table.column('first_name')
|
table.column('first_name')
|
||||||
.type(FieldType.varchar)
|
.type(FieldType.varchar)
|
||||||
.required()
|
.nullable()
|
||||||
|
|
||||||
table.column('last_name')
|
table.column('last_name')
|
||||||
.type(FieldType.varchar)
|
.type(FieldType.varchar)
|
||||||
.required()
|
.nullable()
|
||||||
|
|
||||||
table.column('password_hash')
|
table.column('password_hash')
|
||||||
.type(FieldType.text)
|
.type(FieldType.text)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ErrorWithContext} from '../../util'
|
import {Awaitable, ErrorWithContext} from '../../util'
|
||||||
import {QueryResult} from '../types'
|
import {QueryResult} from '../types'
|
||||||
import {SQLDialect} from '../dialect/SQLDialect'
|
import {SQLDialect} from '../dialect/SQLDialect'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
@@ -68,6 +68,13 @@ export abstract class Connection extends AppClass {
|
|||||||
*/
|
*/
|
||||||
public abstract schema(name?: string): Schema
|
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.
|
* Fire a QueryExecutedEvent for the given query string.
|
||||||
* @param query
|
* @param query
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Connection, ConnectionNotReadyError} from './Connection'
|
|||||||
import {Client} from 'pg'
|
import {Client} from 'pg'
|
||||||
import {Inject} from '../../di'
|
import {Inject} from '../../di'
|
||||||
import {QueryResult} from '../types'
|
import {QueryResult} from '../types'
|
||||||
import {collect} from '../../util'
|
import {Awaitable, collect} from '../../util'
|
||||||
import {SQLDialect} from '../dialect/SQLDialect'
|
import {SQLDialect} from '../dialect/SQLDialect'
|
||||||
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
|
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
@@ -63,13 +63,28 @@ export class PostgresConnection extends Connection {
|
|||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw this.app().errorWrapContext(e, {
|
if ( e instanceof Error ) {
|
||||||
query,
|
throw this.app().errorWrapContext(e, {
|
||||||
connection: this.name,
|
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 {
|
public schema(name?: string): Schema {
|
||||||
return new PostgresSchema(this, name)
|
return new PostgresSchema(this, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import {Directive, OptionDefinition} from '../../cli'
|
|||||||
import {Injectable} from '../../di'
|
import {Injectable} from '../../di'
|
||||||
import {stringToPascal} from '../../util'
|
import {stringToPascal} from '../../util'
|
||||||
import {templateMigration} from '../template/migration'
|
import {templateMigration} from '../template/migration'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that creates migration classes from template.
|
* CLI directive that creates migration classes from template.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class CreateMigrationDirective extends Directive {
|
export class CreateMigrationDirective extends Directive {
|
||||||
getDescription(): string {
|
getDescription(): string {
|
||||||
return 'create a new migration'
|
return 'create a new migration'
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import {Directive, OptionDefinition} from '../../cli'
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
import {Container, Inject, Injectable} from '../../di'
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
import {EventBus} from '../../event/EventBus'
|
import {EventBus} from '../../event/EventBus'
|
||||||
import {Migrator} from '../migrations/Migrator'
|
|
||||||
import {Migrations} from '../services/Migrations'
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||||
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||||
import {EventSubscription} from '../../event/types'
|
import {EventSubscription} from '../../event/types'
|
||||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that applies migrations using the default Migrator.
|
* CLI directive that applies migrations using the default Migrator.
|
||||||
* @fixme Support dry run mode
|
* @fixme Support dry run mode
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class MigrateDirective extends Directive {
|
export class MigrateDirective extends Directive {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrati
|
|||||||
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
||||||
import {EventSubscription} from '../../event/types'
|
import {EventSubscription} from '../../event/types'
|
||||||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI directive that undoes applied migrations using the default Migrator.
|
* CLI directive that undoes applied migrations using the default Migrator.
|
||||||
* @fixme Support dry run mode
|
* @fixme Support dry run mode
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
export class RollbackDirective extends Directive {
|
export class RollbackDirective extends Directive {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly bus!: EventBus
|
protected readonly bus!: EventBus
|
||||||
|
|||||||
@@ -30,19 +30,20 @@ export * from './schema/TableBuilder'
|
|||||||
export * from './schema/Schema'
|
export * from './schema/Schema'
|
||||||
export * from './schema/PostgresSchema'
|
export * from './schema/PostgresSchema'
|
||||||
|
|
||||||
|
export * from './services/Migrations'
|
||||||
|
export * from './migrations/Migrator'
|
||||||
export * from './migrations/NothingToMigrateError'
|
export * from './migrations/NothingToMigrateError'
|
||||||
|
export * from './migrations/events/MigrationEvent'
|
||||||
export * from './migrations/events/ApplyingMigrationEvent'
|
export * from './migrations/events/ApplyingMigrationEvent'
|
||||||
export * from './migrations/events/AppliedMigrationEvent'
|
export * from './migrations/events/AppliedMigrationEvent'
|
||||||
export * from './migrations/events/RollingBackMigrationEvent'
|
export * from './migrations/events/RollingBackMigrationEvent'
|
||||||
export * from './migrations/events/RolledBackMigrationEvent'
|
export * from './migrations/events/RolledBackMigrationEvent'
|
||||||
export * from './migrations/Migration'
|
export * from './migrations/Migration'
|
||||||
export * from './migrations/Migrator'
|
|
||||||
export * from './migrations/MigratorFactory'
|
export * from './migrations/MigratorFactory'
|
||||||
export * from './migrations/DatabaseMigrator'
|
export * from './migrations/DatabaseMigrator'
|
||||||
|
|
||||||
export * from './services/Database'
|
export * from './services/Database'
|
||||||
export * from './services/Models'
|
export * from './services/Models'
|
||||||
export * from './services/Migrations'
|
|
||||||
|
|
||||||
export * from './directive/CreateMigrationDirective'
|
export * from './directive/CreateMigrationDirective'
|
||||||
export * from './directive/MigrateDirective'
|
export * from './directive/MigrateDirective'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {NothingToMigrateError} from './NothingToMigrateError'
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export abstract class Migrator {
|
export abstract class Migrator {
|
||||||
@Inject()
|
@Inject(Migrations, { debug: true })
|
||||||
protected readonly migrations!: Migrations
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
PropertyDependency,
|
PropertyDependency,
|
||||||
isInstantiable,
|
isInstantiable,
|
||||||
DEPENDENCY_KEYS_METADATA_KEY,
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject,
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||||
} from '../../di'
|
} from '../../di'
|
||||||
import {Collection, ErrorWithContext} from '../../util'
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
@@ -17,6 +17,7 @@ import {DatabaseMigrator} from './DatabaseMigrator'
|
|||||||
* and produces an instance of the configured session driver implementation.
|
* and produces an instance of the configured session driver implementation.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@FactoryProducer()
|
||||||
export class MigratorFactory extends AbstractFactory<Migrator> {
|
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly logging!: Logging
|
protected readonly logging!: Logging
|
||||||
|
|||||||
@@ -612,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = this.buildInsertFieldObject()
|
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 returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||||
|
|
||||||
const result = await this.query()
|
const result = await this.query()
|
||||||
@@ -635,6 +637,30 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
|
|||||||
return this
|
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.
|
* 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 {
|
private buildInsertFieldObject(): EscapeValueObject {
|
||||||
const ctor = this.constructor as typeof Model
|
const ctor = this.constructor as typeof Model
|
||||||
|
|
||||||
|
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
|
||||||
|
|
||||||
return getFieldsMeta(this)
|
return getFieldsMeta(this)
|
||||||
.pipe()
|
.pipe()
|
||||||
.unless(ctor.populateKeyOnInsert, fields => {
|
.unless(ctor.populateKeyOnInsert, fields => {
|
||||||
return fields.where('modelKey', '!=', this.keyName())
|
return fields.where('databaseKey', '!=', this.keyName())
|
||||||
})
|
})
|
||||||
.get()
|
.get()
|
||||||
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {Model} from './Model'
|
import {Model} from './Model'
|
||||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||||
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
|
||||||
import {Instantiable} from '../../di'
|
import {Instantiable, StaticClass} from '../../di'
|
||||||
import {ModelResultIterable} from './ModelResultIterable'
|
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`.
|
* 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> {
|
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||||
constructor(
|
constructor(
|
||||||
/** The model class that is created for results of this query. */
|
/** 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()
|
super()
|
||||||
}
|
}
|
||||||
@@ -22,4 +25,45 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
|||||||
public getResultIterable(): AbstractResultIterable<T> {
|
public getResultIterable(): AbstractResultIterable<T> {
|
||||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
|
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')
|
.pluck<string>('column_name')
|
||||||
.each(col => idx.field(col))
|
.each(col => idx.field(col))
|
||||||
})
|
})
|
||||||
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
|
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())
|
||||||
idx.primary()
|
.when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique())
|
||||||
})
|
|
||||||
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
|
|
||||||
idx.unique()
|
|
||||||
})
|
|
||||||
.get()
|
.get()
|
||||||
.flagAsExistingInSchema()
|
.flagAsExistingInSchema()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,15 @@ import {Inject, Singleton} from '../../di'
|
|||||||
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
|
||||||
import {Migration} from '../migrations/Migration'
|
import {Migration} from '../migrations/Migration'
|
||||||
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
|
||||||
import {Migrator} from '../migrations/Migrator'
|
|
||||||
import {UniversalPath} from '../../util'
|
import {UniversalPath} from '../../util'
|
||||||
import {lib} from '../../lib'
|
import {lib} from '../../lib'
|
||||||
import {CommandLine} from '../../cli'
|
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.
|
* Service unit that loads and instantiates migration classes.
|
||||||
*/
|
*/
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class Migrations extends CanonicalInstantiable<Migration> {
|
export class Migrations extends CanonicalInstantiable<Migration> {
|
||||||
@Inject()
|
|
||||||
protected readonly migrator!: Migrator
|
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly cli!: CommandLine
|
protected readonly cli!: CommandLine
|
||||||
|
|
||||||
@@ -38,11 +31,6 @@ export class Migrations extends CanonicalInstantiable<Migration> {
|
|||||||
const basePath = lib().concat('migrations')
|
const basePath = lib().concat('migrations')
|
||||||
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
|
||||||
this.registerNamespace('@extollo', resolver)
|
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> {
|
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {Model} from '../model/Model'
|
import {Model} from '../model/Model'
|
||||||
import {Field} from '../model/Field'
|
import {Field} from '../model/Field'
|
||||||
import {FieldType} from '../types'
|
import {FieldType} from '../types'
|
||||||
|
import {Maybe} from '../../util'
|
||||||
|
import {ModelBuilder} from '../model/ModelBuilder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A model instance which stores records from the ORMCache driver.
|
* 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')
|
@Field(FieldType.timestamp, 'cache_expires')
|
||||||
public cacheExpires?: Date;
|
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 {Container} from '../../di'
|
||||||
import {Cache} from '../../util'
|
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
|
||||||
import {CacheModel} from './CacheModel'
|
import {CacheModel} from './CacheModel'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
|
|||||||
*/
|
*/
|
||||||
export class ORMCache extends Cache {
|
export class ORMCache extends Cache {
|
||||||
public async fetch(key: string): Promise<string | undefined> {
|
public async fetch(key: string): Promise<string | undefined> {
|
||||||
const model = await CacheModel.query<CacheModel>()
|
return (await CacheModel.getCacheKey(key))?.cacheValue
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
|
||||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
|
||||||
.first()
|
|
||||||
|
|
||||||
if ( model ) {
|
|
||||||
return model.cacheValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
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> {
|
public async has(key: string): Promise<boolean> {
|
||||||
return CacheModel.query()
|
return CacheModel.withCacheKey(key)
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
|
||||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
|
||||||
.exists()
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
public async drop(key: string): Promise<void> {
|
public async drop(key: string): Promise<void> {
|
||||||
await CacheModel.query()
|
await CacheModel.query()
|
||||||
.where(CacheModel.qualifyKey(), '=', key)
|
.whereKey(key)
|
||||||
.delete()
|
.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.
|
* Model used to fetch & store sessions from the ORMSession driver.
|
||||||
*/
|
*/
|
||||||
export class SessionModel extends Model<SessionModel> {
|
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')
|
@Field(FieldType.varchar, 'session_uuid')
|
||||||
public uuid!: string;
|
public uuid!: string;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
|
|||||||
*/
|
*/
|
||||||
export type ModelKey = string | number
|
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.
|
* Interface for the result of a query execution.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ block content
|
|||||||
each error in errors
|
each error in errors
|
||||||
p.form-error-message #{error}
|
p.form-error-message #{error}
|
||||||
|
|
||||||
form(method='post' enctype='multipart/form-data')
|
if formAction
|
||||||
block form
|
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
|
block form
|
||||||
.form-label-group
|
.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
|
label(for='inputUsername') Username
|
||||||
|
|
||||||
.form-label-group
|
.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
|
label(for='inputPassword') Password
|
||||||
|
|
||||||
|
|
||||||
@@ -21,4 +21,4 @@ block form
|
|||||||
|
|
||||||
.text-center
|
.text-center
|
||||||
span.small Need an account?
|
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 {Canonical} from './Canonical'
|
||||||
import {Singleton} from '../di'
|
import {Singleton} from '../di'
|
||||||
|
import {Maybe} from '../util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error throw when a duplicate canonical key is registered.
|
* Error throw when a duplicate canonical key is registered.
|
||||||
@@ -46,6 +47,17 @@ export class Canon {
|
|||||||
return this.resources[key] as Canonical<T>
|
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.
|
* Register a canonical resource.
|
||||||
* @param {Canonical} unit
|
* @param {Canonical} unit
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Logging} from './Logging'
|
|||||||
import {Inject} from '../di'
|
import {Inject} from '../di'
|
||||||
import * as nodePath from 'path'
|
import * as nodePath from 'path'
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {isCanonicalReceiver} from '../support/CanonicalReceiver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface describing a definition of a single canonical item loaded from the app.
|
* 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)
|
const definition = await this.buildCanonicalDefinition(entry)
|
||||||
this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${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)
|
this.canon.registerCanonical(this)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Inject, Singleton} from '../di'
|
import {Inject, Singleton} from '../di'
|
||||||
import {HTTPStatus, withTimeout} from '../util'
|
import {ErrorWithContext, HTTPStatus, withTimeout} from '../util'
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
|
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
|
||||||
import {Logging} from './Logging'
|
import {Logging} from './Logging'
|
||||||
@@ -82,7 +82,8 @@ export class HTTPServer extends Unit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get handler(): RequestListener {
|
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) => {
|
return async (request: IncomingMessage, response: ServerResponse) => {
|
||||||
const extolloReq = new Request(request, response)
|
const extolloReq = new Request(request, response)
|
||||||
@@ -113,7 +114,11 @@ export class HTTPServer extends Unit {
|
|||||||
try {
|
try {
|
||||||
await this.kernel.handle(extolloReq)
|
await this.kernel.handle(extolloReq)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await error(e).write(extolloReq)
|
if ( e instanceof Error ) {
|
||||||
|
await error(e).write(extolloReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
await error(new ErrorWithContext('Unknown error occurred.', { e }))
|
||||||
}
|
}
|
||||||
|
|
||||||
await extolloReq.response.send()
|
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. */
|
/** 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}>()
|
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()
|
const now = new Date()
|
||||||
return MemoryCache.cacheItems
|
return MemoryCache.cacheItems
|
||||||
.where('key', '=', key)
|
.where('key', '=', key)
|
||||||
@@ -41,4 +44,41 @@ export class MemoryCache extends Cache {
|
|||||||
public drop(key: string): Awaitable<void> {
|
public drop(key: string): Awaitable<void> {
|
||||||
MemoryCache.cacheItems = MemoryCache.cacheItems.where('key', '!=', key)
|
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.
|
* Store the given value in the cache by key.
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @param {string} value
|
* @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.
|
* Check if the cache has the given key.
|
||||||
@@ -30,4 +31,38 @@ export abstract class Cache {
|
|||||||
* @param {string} key
|
* @param {string} key
|
||||||
*/
|
*/
|
||||||
public abstract drop(key: string): Awaitable<void>;
|
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 { Cache } from './Cache'
|
||||||
import { Collection } from '../collection/Collection'
|
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.
|
* 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> {
|
public async drop(key: string): Promise<void> {
|
||||||
this.items = this.items.whereNot('key', '=', key)
|
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/Cache'
|
||||||
export * from './cache/InMemCache'
|
export * from './cache/InMemCache'
|
||||||
|
|
||||||
@@ -10,6 +14,7 @@ export * from './collection/where'
|
|||||||
export * from './const/http'
|
export * from './const/http'
|
||||||
|
|
||||||
export * from './error/ErrorWithContext'
|
export * from './error/ErrorWithContext'
|
||||||
|
export * from './error/MethodNotSupportedError'
|
||||||
|
|
||||||
export * from './logging/Logger'
|
export * from './logging/Logger'
|
||||||
export * from './logging/StandardLogger'
|
export * from './logging/StandardLogger'
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class BehaviorSubject<T> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( e instanceof UnsubscribeError ) {
|
if ( e instanceof UnsubscribeError ) {
|
||||||
this.subscribers = this.subscribers.filter(x => x !== subscriber)
|
this.subscribers = this.subscribers.filter(x => x !== subscriber)
|
||||||
} else if (subscriber.error) {
|
} else if (subscriber.error && e instanceof Error) {
|
||||||
await subscriber.error(e)
|
await subscriber.error(e)
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
@@ -181,7 +181,7 @@ export class BehaviorSubject<T> {
|
|||||||
try {
|
try {
|
||||||
await subscriber.complete(finalValue)
|
await subscriber.complete(finalValue)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( subscriber.error ) {
|
if ( subscriber.error && e instanceof Error ) {
|
||||||
await subscriber.error(e)
|
await subscriber.error(e)
|
||||||
} else {
|
} else {
|
||||||
throw e
|
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.
|
* 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.
|
* 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> {
|
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
|
||||||
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
if ( (typeof check === 'function' && check(this.subject)) || check ) {
|
||||||
Pipe.wrap(op(this.subject))
|
return Pipe.wrap(op(this.subject))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -115,8 +115,7 @@ export class Pipe<T> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
Pipe.wrap(op(this.subject))
|
return Pipe.wrap(op(this.subject))
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +157,8 @@ export type AsyncPipeResolver<T> = () => Awaitable<T>
|
|||||||
*/
|
*/
|
||||||
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
|
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.
|
* 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()))
|
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
|
* Apply an operator to the pipe, but return the reference
|
||||||
* to the current pipe. The operator is resolved when the
|
* to the current pipe. The operator is resolved when the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export function isDebugging(key: string): boolean {
|
export function isDebugging(key: string): boolean {
|
||||||
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
|
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
|
|
||||||
return process.env[env] === 'yes'
|
return process.env[env] === 'yes'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as mime from 'mime-types'
|
|||||||
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
import {FileNotFoundError, Filesystem} from './path/Filesystem'
|
||||||
import {Collection} from '../collection/Collection'
|
import {Collection} from '../collection/Collection'
|
||||||
import {Readable, Writable} from 'stream'
|
import {Readable, Writable} from 'stream'
|
||||||
|
import {Pipe} from './Pipe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item that could represent a path.
|
* An item that could represent a path.
|
||||||
@@ -82,6 +83,8 @@ export class UniversalPath {
|
|||||||
|
|
||||||
protected resourceLocalPath!: string
|
protected resourceLocalPath!: string
|
||||||
|
|
||||||
|
protected resourceQuery: URLSearchParams = new URLSearchParams()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
/**
|
/**
|
||||||
* The path string this path refers to.
|
* The path string this path refers to.
|
||||||
@@ -94,6 +97,10 @@ export class UniversalPath {
|
|||||||
) {
|
) {
|
||||||
this.setPrefix()
|
this.setPrefix()
|
||||||
this.setLocal()
|
this.setLocal()
|
||||||
|
|
||||||
|
if ( this.isRemote ) {
|
||||||
|
this.resourceQuery = (new URL(this.toRemote)).searchParams
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +147,13 @@ export class UniversalPath {
|
|||||||
return new UniversalPath(this.initial)
|
return new UniversalPath(this.initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URLSearchParams for this resource.
|
||||||
|
*/
|
||||||
|
get query(): URLSearchParams {
|
||||||
|
return this.resourceQuery
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the string of this resource.
|
* Get the string of this resource.
|
||||||
*/
|
*/
|
||||||
@@ -183,7 +197,8 @@ export class UniversalPath {
|
|||||||
* Get the fully-prefixed path to this resource.
|
* Get the fully-prefixed path to this resource.
|
||||||
*/
|
*/
|
||||||
get toRemote(): string {
|
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
|
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(),
|
isFile: stat.isFile(),
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ( e?.code === 'ENOENT' ) {
|
if ( (e as any)?.code === 'ENOENT' ) {
|
||||||
return {
|
return {
|
||||||
path: new UniversalPath(args.storePath, this),
|
path: new UniversalPath(args.storePath, this),
|
||||||
exists: false,
|
exists: false,
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ export function withTimeout<T>(timeout: number, promise: Promise<T>): TimeoutSub
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
let expired = false
|
let expired = false
|
||||||
let resolved = false
|
let resolved = false
|
||||||
setTimeout(() => {
|
if ( timeout ) {
|
||||||
expired = true
|
setTimeout(() => {
|
||||||
if ( !resolved ) {
|
expired = true
|
||||||
timeoutHandler()
|
if ( !resolved ) {
|
||||||
}
|
timeoutHandler()
|
||||||
}, timeout)
|
}
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
const result: T = await promise
|
const result: T = await promise
|
||||||
resolved = true
|
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 {
|
return {
|
||||||
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
|
||||||
debug: this.debug,
|
debug: this.debug,
|
||||||
compileDebug: this.debug,
|
// compileDebug: this.debug,
|
||||||
globals: [],
|
globals: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user