Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d1dcc87fb | |||
| 3efbfecf9d | |||
| a1d04d652e | |||
| 5940b6e2b3 | |||
|
074a3187eb
|
|||
|
26e0444e40
|
|||
|
fcce28081b
|
|||
|
e86cf420df
|
|||
|
e33d8dee8f
|
|||
|
39d97d6e14
|
|||
|
f496046461
|
|||
|
b3b5b169e8
|
|||
|
5d960e6186
|
|||
|
cf6d14abca
|
|||
|
faa8a31102
|
|||
|
7506d6567d
|
|||
|
a69c81ed35
|
|||
|
36b451c32b
|
|||
|
9796a7277e
|
|||
|
f00233d49a
|
|||
|
91abcdf8ef
|
|||
|
c264d45927
|
|||
|
61731c4ebd
|
|||
|
dab3d006c8
|
|||
|
cd9bec7c5e
|
26
.drone.yml
26
.drone.yml
@@ -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
|
||||||
@@ -217,18 +226,3 @@ steps:
|
|||||||
when:
|
when:
|
||||||
status: failure
|
status: failure
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
- name: trigger documentation build
|
|
||||||
image: plugins/downstream
|
|
||||||
settings:
|
|
||||||
server: https://ci.garrettmills.dev
|
|
||||||
token:
|
|
||||||
from_secret: drone_token
|
|
||||||
fork: false
|
|
||||||
last_successful: true
|
|
||||||
deploy: production
|
|
||||||
repositories:
|
|
||||||
- Extollo/docs@master
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event: tag
|
|
||||||
|
|||||||
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>
|
||||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@extollo/lib",
|
"name": "@extollo/lib",
|
||||||
"version": "0.3.0",
|
"version": "0.5.1",
|
||||||
"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",
|
||||||
@@ -8,21 +8,31 @@
|
|||||||
"lib": "lib"
|
"lib": "lib"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atao60/fse-cli": "^0.1.6",
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/busboy": "^0.2.3",
|
"@types/busboy": "^0.2.3",
|
||||||
|
"@types/cli-table": "^0.3.0",
|
||||||
|
"@types/ioredis": "^4.26.6",
|
||||||
|
"@types/mime-types": "^2.1.0",
|
||||||
"@types/mkdirp": "^1.0.1",
|
"@types/mkdirp": "^1.0.1",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.17.4",
|
||||||
"@types/pg": "^8.6.0",
|
"@types/pg": "^8.6.0",
|
||||||
"@types/pluralize": "^0.0.29",
|
"@types/pluralize": "^0.0.29",
|
||||||
"@types/pug": "^2.0.4",
|
"@types/pug": "^2.0.4",
|
||||||
"@types/rimraf": "^3.0.0",
|
"@types/rimraf": "^3.0.0",
|
||||||
"@types/ssh2": "^0.5.46",
|
"@types/ssh2": "^0.5.46",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
|
"bcrypt": "^5.0.1",
|
||||||
"busboy": "^0.3.1",
|
"busboy": "^0.3.1",
|
||||||
|
"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",
|
||||||
"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",
|
||||||
@@ -38,8 +48,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"prebuild": "pnpm run lint",
|
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||||
"build": "tsc",
|
|
||||||
"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",
|
||||||
@@ -61,5 +70,11 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||||
"@typescript-eslint/parser": "^4.26.0",
|
"@typescript-eslint/parser": "^4.26.0",
|
||||||
"eslint": "^7.27.0"
|
"eslint": "^7.27.0"
|
||||||
|
},
|
||||||
|
"extollo": {
|
||||||
|
"discover": true,
|
||||||
|
"units": {
|
||||||
|
"discover": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2504
pnpm-lock.yaml
generated
2504
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 {
|
||||||
|
|
||||||
|
}
|
||||||
40
src/auth/Authentication.ts
Normal file
40
src/auth/Authentication.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
||||||
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
import {CanonicalResolver} from '../service/Canonical'
|
||||||
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
|
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
||||||
|
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||||
|
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||||
|
import {Middlewares} from '../service/Middlewares'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit class that bootstraps the authentication framework.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class Authentication extends Unit {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly middleware!: Middlewares
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.container()
|
||||||
|
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the canonical namespace resolver for auth middleware.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
||||||
|
return (key: string) => {
|
||||||
|
return ({
|
||||||
|
web: SessionAuthMiddleware,
|
||||||
|
required: AuthRequiredMiddleware,
|
||||||
|
guest: GuestRequiredMiddleware,
|
||||||
|
})[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/auth/NotAuthorizedError.ts
Normal file
11
src/auth/NotAuthorizedError.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import {HTTPError} from '../http/HTTPError'
|
||||||
|
import {HTTPStatus} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a user attempts an action that they are not authorized to perform.
|
||||||
|
*/
|
||||||
|
export class NotAuthorizedError extends HTTPError {
|
||||||
|
constructor(message = 'Not Authorized') {
|
||||||
|
super(HTTPStatus.FORBIDDEN, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/auth/SecurityContext.ts
Normal file
151
src/auth/SecurityContext.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import {Inject, Injectable} from '../di'
|
||||||
|
import {EventBus} from '../event/EventBus'
|
||||||
|
import {Awaitable, Maybe} from '../util'
|
||||||
|
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||||
|
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||||
|
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||||
|
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base-class for a context that authenticates users and manages security.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class SecurityContext {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
/** The currently authenticated user, if one exists. */
|
||||||
|
private authenticatedUser?: Authenticatable
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** The repository from which to draw users. */
|
||||||
|
public readonly repository: AuthenticatableRepository,
|
||||||
|
|
||||||
|
/** The name of this context. */
|
||||||
|
public readonly name: string,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the context is created. Can be used by child-classes to do setup work.
|
||||||
|
*/
|
||||||
|
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the given user, without persisting the authentication.
|
||||||
|
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
async authenticateOnce(user: Authenticatable): Promise<void> {
|
||||||
|
this.authenticatedUser = user
|
||||||
|
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the given user and persist the authentication.
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
async authenticate(user: Authenticatable): Promise<void> {
|
||||||
|
this.authenticatedUser = user
|
||||||
|
await this.persist()
|
||||||
|
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate a user based on their credentials.
|
||||||
|
* If the credentials are valid, the user will be authenticated, but the authentication
|
||||||
|
* will not be persisted. That is, when the lifecycle ends, the user will be
|
||||||
|
* unauthenticated implicitly.
|
||||||
|
* @param credentials
|
||||||
|
*/
|
||||||
|
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||||
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
|
if ( user ) {
|
||||||
|
await this.authenticateOnce(user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate a user based on their credentials.
|
||||||
|
* If the credentials are valid, the user will be authenticated and the
|
||||||
|
* authentication will be persisted.
|
||||||
|
* @param credentials
|
||||||
|
*/
|
||||||
|
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||||
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
|
if ( user ) {
|
||||||
|
await this.authenticate(user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticate the current user, if one exists, but do not persist the change.
|
||||||
|
*/
|
||||||
|
async flushOnce(): Promise<void> {
|
||||||
|
const user = this.authenticatedUser
|
||||||
|
if ( user ) {
|
||||||
|
this.authenticatedUser = undefined
|
||||||
|
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticate the current user, if one exists, and persist the change.
|
||||||
|
*/
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
const user = this.authenticatedUser
|
||||||
|
if ( user ) {
|
||||||
|
this.authenticatedUser = undefined
|
||||||
|
await this.persist()
|
||||||
|
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assuming a user is still authenticated in the context,
|
||||||
|
* try to look up and fill in the user.
|
||||||
|
*/
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
const credentials = await this.getCredentials()
|
||||||
|
this.logging.debug('resume:')
|
||||||
|
this.logging.debug(credentials)
|
||||||
|
const user = await this.repository.getByCredentials(credentials)
|
||||||
|
if ( user ) {
|
||||||
|
this.authenticatedUser = user
|
||||||
|
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the current state of the security context to whatever storage
|
||||||
|
* medium the context's host provides.
|
||||||
|
*/
|
||||||
|
abstract persist(): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the credentials for the current user from whatever storage medium
|
||||||
|
* the context's host provides.
|
||||||
|
*/
|
||||||
|
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently authenticated user, if one exists.
|
||||||
|
*/
|
||||||
|
getUser(): Maybe<Authenticatable> {
|
||||||
|
return this.authenticatedUser
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if there is a currently authenticated user.
|
||||||
|
*/
|
||||||
|
hasUser(): boolean {
|
||||||
|
this.logging.debug('hasUser?')
|
||||||
|
this.logging.debug(this.authenticatedUser)
|
||||||
|
return Boolean(this.authenticatedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
20
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {FormRequest, ValidationRules} from '../../forms'
|
||||||
|
import {Is, Str} from '../../forms/rules/rules'
|
||||||
|
import {Singleton} from '../../di'
|
||||||
|
import {AuthenticatableCredentials} from '../types'
|
||||||
|
|
||||||
|
@Singleton()
|
||||||
|
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||||
|
protected getRules(): ValidationRules {
|
||||||
|
return {
|
||||||
|
identifier: [
|
||||||
|
Is.required,
|
||||||
|
Str.lengthMin(1),
|
||||||
|
],
|
||||||
|
credential: [
|
||||||
|
Is.required,
|
||||||
|
Str.lengthMin(1),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/auth/config.ts
Normal file
29
src/auth/config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {Instantiable} from '../di'
|
||||||
|
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||||
|
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||||
|
*/
|
||||||
|
export interface AuthenticatableRepositoryMapping {
|
||||||
|
orm: Instantiable<ORMUserRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String mapping of AuthenticatableRepository implementations.
|
||||||
|
*/
|
||||||
|
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
||||||
|
orm: ORMUserRepository,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for making the auth config type-safe.
|
||||||
|
*/
|
||||||
|
export interface AuthConfig {
|
||||||
|
repositories: {
|
||||||
|
session: keyof AuthenticatableRepositoryMapping,
|
||||||
|
},
|
||||||
|
sources?: {
|
||||||
|
[key: string]: OAuth2LoginConfig,
|
||||||
|
},
|
||||||
|
}
|
||||||
32
src/auth/contexts/SessionSecurityContext.ts
Normal file
32
src/auth/contexts/SessionSecurityContext.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Session} from '../../http/session/Session'
|
||||||
|
import {Awaitable} from '../../util'
|
||||||
|
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security context implementation that uses the session as storage.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SessionSecurityContext extends SecurityContext {
|
||||||
|
@Inject()
|
||||||
|
protected readonly session!: Session
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** The repository from which to draw users. */
|
||||||
|
public readonly repository: AuthenticatableRepository,
|
||||||
|
) {
|
||||||
|
super(repository, 'session')
|
||||||
|
}
|
||||||
|
|
||||||
|
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||||
|
return {
|
||||||
|
identifier: '',
|
||||||
|
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persist(): Awaitable<void> {
|
||||||
|
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/auth/event/UserAuthenticatedEvent.ts
Normal file
27
src/auth/event/UserAuthenticatedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {Event} from '../../event/Event'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {Awaitable, JSONState} from '../../util'
|
||||||
|
import {Authenticatable} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a user is authenticated.
|
||||||
|
*/
|
||||||
|
export class UserAuthenticatedEvent extends Event {
|
||||||
|
constructor(
|
||||||
|
public readonly user: Authenticatable,
|
||||||
|
public readonly context: SecurityContext,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return {
|
||||||
|
user: await this.user.dehydrate(),
|
||||||
|
contextName: this.context.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
// TODO fill this in
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
27
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {Event} from '../../event/Event'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {Awaitable, JSONState} from '../../util'
|
||||||
|
import {Authenticatable} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a security context for a given user is resumed.
|
||||||
|
*/
|
||||||
|
export class UserAuthenticationResumedEvent extends Event {
|
||||||
|
constructor(
|
||||||
|
public readonly user: Authenticatable,
|
||||||
|
public readonly context: SecurityContext,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return {
|
||||||
|
user: await this.user.dehydrate(),
|
||||||
|
contextName: this.context.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
// TODO fill this in
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/auth/event/UserFlushedEvent.ts
Normal file
27
src/auth/event/UserFlushedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {Event} from '../../event/Event'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {Awaitable, JSONState} from '../../util'
|
||||||
|
import {Authenticatable} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a user is unauthenticated.
|
||||||
|
*/
|
||||||
|
export class UserFlushedEvent extends Event {
|
||||||
|
constructor(
|
||||||
|
public readonly user: Authenticatable,
|
||||||
|
public readonly context: SecurityContext,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return {
|
||||||
|
user: await this.user.dehydrate(),
|
||||||
|
contextName: this.context.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
// TODO fill this in
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/auth/index.ts
Normal file
26
src/auth/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './NotAuthorizedError'
|
||||||
|
|
||||||
|
export * from './SecurityContext'
|
||||||
|
|
||||||
|
export * from './event/UserAuthenticatedEvent'
|
||||||
|
export * from './event/UserFlushedEvent'
|
||||||
|
export * from './event/UserAuthenticationResumedEvent'
|
||||||
|
|
||||||
|
export * from './contexts/SessionSecurityContext'
|
||||||
|
|
||||||
|
export * from './orm/ORMUser'
|
||||||
|
export * from './orm/ORMUserRepository'
|
||||||
|
|
||||||
|
export * from './middleware/AuthRequiredMiddleware'
|
||||||
|
export * from './middleware/GuestRequiredMiddleware'
|
||||||
|
export * from './middleware/SessionAuthMiddleware'
|
||||||
|
|
||||||
|
export * from './Authentication'
|
||||||
|
|
||||||
|
export * from './config'
|
||||||
|
|
||||||
|
export * from './basic-ui/BasicLoginFormRequest'
|
||||||
|
export * from './basic-ui/BasicLoginController'
|
||||||
|
|
||||||
|
export * from './external/oauth2/OAuth2LoginController'
|
||||||
34
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
34
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {Session} from '../../http/session/Session'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRequiredMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly session!: Session
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
if ( !this.security.hasUser() ) {
|
||||||
|
this.session.set('auth.intention', this.request.url)
|
||||||
|
|
||||||
|
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
||||||
|
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
||||||
|
} else {
|
||||||
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
28
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GuestRequiredMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
if ( this.security.hasUser() ) {
|
||||||
|
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
|
||||||
|
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
|
||||||
|
} else {
|
||||||
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
40
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {AuthenticatableRepository} from '../types'
|
||||||
|
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
|
||||||
|
import {SecurityContext} from '../SecurityContext'
|
||||||
|
import {ORMUserRepository} from '../orm/ORMUserRepository'
|
||||||
|
import {AuthConfig, AuthenticatableRepositories} from '../config'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a SessionSecurityContext into the request and attempts to
|
||||||
|
* resume the user's authentication.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SessionAuthMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
this.logging.debug('Applying session auth middleware...')
|
||||||
|
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||||
|
this.request.registerSingletonInstance(SecurityContext, context)
|
||||||
|
await context.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the correct AuthenticatableRepository based on the auth config.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getRepository(): AuthenticatableRepository {
|
||||||
|
const config: AuthConfig | undefined = this.config.get('auth')
|
||||||
|
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
|
||||||
|
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/auth/orm/ORMUser.ts
Normal file
64
src/auth/orm/ORMUser.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {Field, FieldType, Model} from '../../orm'
|
||||||
|
import {Authenticatable, AuthenticatableIdentifier} from '../types'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
import * as bcrypt from 'bcrypt'
|
||||||
|
import {Awaitable, JSONState} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic ORM-driven user class.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||||
|
|
||||||
|
protected static table = 'users'
|
||||||
|
|
||||||
|
protected static key = 'user_id'
|
||||||
|
|
||||||
|
/** The primary key of the user in the table. */
|
||||||
|
@Field(FieldType.serial, 'user_id')
|
||||||
|
public userId!: number
|
||||||
|
|
||||||
|
/** The unique string-identifier of the user. */
|
||||||
|
@Field(FieldType.varchar)
|
||||||
|
public username!: string
|
||||||
|
|
||||||
|
/** The user's first name. */
|
||||||
|
@Field(FieldType.varchar, 'first_name')
|
||||||
|
public firstName?: string
|
||||||
|
|
||||||
|
/** The user's last name. */
|
||||||
|
@Field(FieldType.varchar, 'last_name')
|
||||||
|
public lastName?: string
|
||||||
|
|
||||||
|
/** The hashed and salted password of the user. */
|
||||||
|
@Field(FieldType.varchar, 'password_hash')
|
||||||
|
public passwordHash!: string
|
||||||
|
|
||||||
|
/** Human-readable display name of the user. */
|
||||||
|
getDisplayIdentifier(): string {
|
||||||
|
return `${this.firstName} ${this.lastName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique identifier of the user. */
|
||||||
|
getIdentifier(): AuthenticatableIdentifier {
|
||||||
|
return this.username
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the provided password is valid for the user. */
|
||||||
|
verifyPassword(password: string): Awaitable<boolean> {
|
||||||
|
return bcrypt.compare(password, this.passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change the user's password, hashing it. */
|
||||||
|
async setPassword(password: string): Promise<void> {
|
||||||
|
this.passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return this.toQueryRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rehydrate(state: JSONState): Promise<void> {
|
||||||
|
await this.assumeFromSource(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/auth/orm/ORMUserRepository.ts
Normal file
65
src/auth/orm/ORMUserRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Authenticatable,
|
||||||
|
AuthenticatableCredentials,
|
||||||
|
AuthenticatableIdentifier,
|
||||||
|
AuthenticatableRepository,
|
||||||
|
} from '../types'
|
||||||
|
import {Awaitable, Maybe} from '../../util'
|
||||||
|
import {ORMUser} from './ORMUser'
|
||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user repository implementation that looks up users stored in the database.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ORMUserRepository extends AuthenticatableRepository {
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/** Look up the user by their username. */
|
||||||
|
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||||
|
return ORMUser.query<ORMUser>()
|
||||||
|
.where('username', '=', id)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to look up a user by the credentials provided.
|
||||||
|
* If a securityIdentifier is specified, look up the user by username.
|
||||||
|
* If username/password are specified, look up the user and verify the password.
|
||||||
|
* @param credentials
|
||||||
|
*/
|
||||||
|
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||||
|
if ( !credentials.identifier && credentials.credential ) {
|
||||||
|
return ORMUser.query<ORMUser>()
|
||||||
|
.where('username', '=', credentials.credential)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( credentials.identifier && credentials.credential ) {
|
||||||
|
const user = await ORMUser.query<ORMUser>()
|
||||||
|
.where('username', '=', credentials.identifier)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||||
|
if ( await this.getByCredentials(credentials) ) {
|
||||||
|
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||||
|
identifier: credentials.identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = <ORMUser> this.injector.make(ORMUser)
|
||||||
|
user.username = credentials.identifier
|
||||||
|
await user.setPassword(credentials.credential)
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/auth/types.ts
Normal file
43
src/auth/types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||||
|
|
||||||
|
/** Value that can be used to uniquely identify a user. */
|
||||||
|
export type AuthenticatableIdentifier = string | number
|
||||||
|
|
||||||
|
export interface AuthenticatableCredentials {
|
||||||
|
identifier: string,
|
||||||
|
credential: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for entities that can be authenticated.
|
||||||
|
*/
|
||||||
|
export abstract class Authenticatable implements Rehydratable {
|
||||||
|
|
||||||
|
/** Get the unique identifier of the user. */
|
||||||
|
abstract getIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
|
/** Get the human-readable identifier of the user. */
|
||||||
|
abstract getDisplayIdentifier(): string
|
||||||
|
|
||||||
|
abstract dehydrate(): Promise<JSONState>
|
||||||
|
|
||||||
|
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a repository that stores and recalls users.
|
||||||
|
*/
|
||||||
|
export abstract class AuthenticatableRepository {
|
||||||
|
|
||||||
|
/** Look up the user by their unique identifier. */
|
||||||
|
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to look up and verify a user by their credentials.
|
||||||
|
* Returns the user if the credentials are valid.
|
||||||
|
* @param credentials
|
||||||
|
*/
|
||||||
|
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
|
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/cli/directive/RouteDirective.ts
Normal file
71
src/cli/directive/RouteDirective.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../Directive'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import Table = require('cli-table')
|
||||||
|
import {RouteHandler} from '../../http/routing/Route'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RouteDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'Get information about a specific route'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['route']
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'{route} | the path of the route',
|
||||||
|
'--method -m {value} | the HTTP method of the route',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const method: string | undefined = this.option('method')
|
||||||
|
?.toLowerCase()
|
||||||
|
?.trim()
|
||||||
|
|
||||||
|
const route: string = this.option('route')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
this.routing.getCompiled()
|
||||||
|
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
|
||||||
|
.tap(matches => {
|
||||||
|
if ( !matches.length ) {
|
||||||
|
this.error('No matching routes found. (Use `./ex routes` to list)')
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.each(match => {
|
||||||
|
const pre = match.getMiddlewares()
|
||||||
|
.where('stage', '=', 'pre')
|
||||||
|
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||||
|
|
||||||
|
const post = match.getMiddlewares()
|
||||||
|
.where('stage', '=', 'post')
|
||||||
|
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||||
|
|
||||||
|
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
|
||||||
|
|
||||||
|
const table = new Table({
|
||||||
|
head: ['Stage', 'Handler'],
|
||||||
|
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
|
||||||
|
})
|
||||||
|
|
||||||
|
table.push(...pre.toArray())
|
||||||
|
table.push(['handler', match.getDisplayableHandler()])
|
||||||
|
table.push(...post.toArray())
|
||||||
|
|
||||||
|
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handlerToString(handler: RouteHandler): string {
|
||||||
|
return typeof handler === 'string' ? handler : '(anonymous function)'
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/cli/directive/RoutesDirective.ts
Normal file
33
src/cli/directive/RoutesDirective.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {Directive} from '../Directive'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import Table = require('cli-table')
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoutesDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'List routes registered in the application'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['routes']
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length)
|
||||||
|
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
|
||||||
|
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()])
|
||||||
|
|
||||||
|
const table = new Table({
|
||||||
|
head: ['Route', 'Handler'],
|
||||||
|
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
|
||||||
|
})
|
||||||
|
|
||||||
|
table.push(...rows.toArray())
|
||||||
|
|
||||||
|
this.info('\n' + table)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export class ShellDirective extends Directive {
|
|||||||
async handle(): Promise<void> {
|
async handle(): Promise<void> {
|
||||||
const state: any = {
|
const state: any = {
|
||||||
app: this.app(),
|
app: this.app(),
|
||||||
|
lib: await import('../../index'),
|
||||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {Directive} from '../Directive'
|
|||||||
import {ShellDirective} from '../directive/ShellDirective'
|
import {ShellDirective} from '../directive/ShellDirective'
|
||||||
import {TemplateDirective} from '../directive/TemplateDirective'
|
import {TemplateDirective} from '../directive/TemplateDirective'
|
||||||
import {RunDirective} from '../directive/RunDirective'
|
import {RunDirective} from '../directive/RunDirective'
|
||||||
|
import {RoutesDirective} from '../directive/RoutesDirective'
|
||||||
|
import {RouteDirective} from '../directive/RouteDirective'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit that takes the place of the final unit in the application that handles
|
* Unit that takes the place of the final unit in the application that handles
|
||||||
@@ -42,6 +44,8 @@ export class CommandLineApplication extends Unit {
|
|||||||
this.cli.registerDirective(ShellDirective)
|
this.cli.registerDirective(ShellDirective)
|
||||||
this.cli.registerDirective(TemplateDirective)
|
this.cli.registerDirective(TemplateDirective)
|
||||||
this.cli.registerDirective(RunDirective)
|
this.cli.registerDirective(RunDirective)
|
||||||
|
this.cli.registerDirective(RoutesDirective)
|
||||||
|
this.cli.registerDirective(RouteDirective)
|
||||||
|
|
||||||
const argv = process.argv.slice(2)
|
const argv = process.argv.slice(2)
|
||||||
const match = this.cli.getDirectives()
|
const match = this.cli.getDirectives()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable} 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,6 +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, 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
|
||||||
@@ -16,13 +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())
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -42,11 +66,36 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge all factories and instances of the given key from this container.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
purge(key: DependencyKey): this {
|
||||||
|
this.factories = this.factories.filter(x => !x.match(key))
|
||||||
|
this.release(key)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all stored instances of the given key from this container.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
release(key: DependencyKey): this {
|
||||||
|
this.instances = this.instances.filter(x => x.key !== key)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a basic instantiable class as a standard Factory with this container.
|
* Register a basic instantiable class as a standard Factory with this container.
|
||||||
* @param {Instantiable} dependency
|
* @param {Instantiable} dependency
|
||||||
@@ -113,7 +162,7 @@ export class Container {
|
|||||||
* @param staticClass
|
* @param staticClass
|
||||||
* @param instance
|
* @param instance
|
||||||
*/
|
*/
|
||||||
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T): this {
|
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||||
if ( this.resolve(staticClass) ) {
|
if ( this.resolve(staticClass) ) {
|
||||||
throw new DuplicateFactoryKeyError(staticClass)
|
throw new DuplicateFactoryKeyError(staticClass)
|
||||||
}
|
}
|
||||||
@@ -147,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
|
||||||
@@ -209,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
src/di/ContainerBlueprint.ts
Normal file
107
src/di/ContainerBlueprint.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||||
|
import NamedFactory from './factory/NamedFactory'
|
||||||
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
|
import {Factory} from './factory/Factory'
|
||||||
|
import {ClosureFactory} from './factory/ClosureFactory'
|
||||||
|
|
||||||
|
/** Simple type alias for a callback to a container's onResolve method. */
|
||||||
|
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint for newly-created containers.
|
||||||
|
*
|
||||||
|
* This is used to allow global helpers like `@Singleton()`
|
||||||
|
* or `@CLIDirective()` while still supporting multiple
|
||||||
|
* global Container instances at once.
|
||||||
|
*/
|
||||||
|
export class ContainerBlueprint {
|
||||||
|
private static instance?: ContainerBlueprint
|
||||||
|
|
||||||
|
public static getContainerBlueprint(): ContainerBlueprint {
|
||||||
|
if ( !this.instance ) {
|
||||||
|
this.instance = new ContainerBlueprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
protected factories: (() => AbstractFactory<any>)[] = []
|
||||||
|
|
||||||
|
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
|
||||||
|
|
||||||
|
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register some factory class with the container. Should take no construction params.
|
||||||
|
* @param factory
|
||||||
|
*/
|
||||||
|
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
|
||||||
|
this.constructableFactories.push(factory)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a basic instantiable class as a standard Factory with this container,
|
||||||
|
* identified by a string name rather than static class.
|
||||||
|
* @param {string} name - unique name to identify the factory in the container
|
||||||
|
* @param {Instantiable} dependency
|
||||||
|
*/
|
||||||
|
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||||
|
this.factories.push(() => new NamedFactory(name, dependency))
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a basic instantiable class as a standard Factory with this container.
|
||||||
|
* @param {Instantiable} dependency
|
||||||
|
*/
|
||||||
|
register(dependency: Instantiable<any>): this {
|
||||||
|
this.factories.push(() => new Factory(dependency))
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a producer function as a ClosureFactory with this container.
|
||||||
|
* @param key
|
||||||
|
* @param producer
|
||||||
|
*/
|
||||||
|
registerProducer(key: DependencyKey, producer: () => any): this {
|
||||||
|
this.factories.push(() => new ClosureFactory(key, producer))
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of factory instances in the blueprint.
|
||||||
|
*/
|
||||||
|
resolve(): AbstractFactory<any>[] {
|
||||||
|
return this.factories.map(x => x())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an onResolve callback to be added to all newly-created containers.
|
||||||
|
* @param key
|
||||||
|
* @param callback
|
||||||
|
*/
|
||||||
|
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
|
||||||
|
this.resolutionCallbacks.push({
|
||||||
|
key,
|
||||||
|
callback,
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of static Factory classes that need to be instantiated by
|
||||||
|
* the container itself.
|
||||||
|
*/
|
||||||
|
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
|
||||||
|
return [...this.constructableFactories]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of DependencyKey-callback pairs to register with new containers.
|
||||||
|
*/
|
||||||
|
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
|
||||||
|
return [...this.resolutionCallbacks]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Container, MaybeDependency, MaybeFactory} from './Container'
|
import {Container, MaybeDependency, MaybeFactory} from './Container'
|
||||||
import {DependencyKey} from './types'
|
import {DependencyKey, Instantiable, StaticClass} from './types'
|
||||||
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container that uses some parent container as a base, but
|
* A container that uses some parent container as a base, but
|
||||||
@@ -30,6 +31,8 @@ export class ScopedContainer extends Container {
|
|||||||
return new ScopedContainer(container)
|
return new ScopedContainer(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveParentScope = true
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private parentContainer: Container,
|
private parentContainer: Container,
|
||||||
) {
|
) {
|
||||||
@@ -38,11 +41,11 @@ export class ScopedContainer extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasInstance(key: DependencyKey): boolean {
|
hasInstance(key: DependencyKey): boolean {
|
||||||
return super.hasInstance(key) || this.parentContainer.hasInstance(key)
|
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
hasKey(key: DependencyKey): boolean {
|
hasKey(key: DependencyKey): boolean {
|
||||||
return super.hasKey(key) || this.parentContainer.hasKey(key)
|
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||||
@@ -51,8 +54,10 @@ export class ScopedContainer extends Container {
|
|||||||
return inst
|
return inst
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( this.resolveParentScope ) {
|
||||||
return this.parentContainer.getExistingInstance(key)
|
return this.parentContainer.getExistingInstance(key)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolve(key: DependencyKey): MaybeFactory<any> {
|
resolve(key: DependencyKey): MaybeFactory<any> {
|
||||||
const factory = super.resolve(key)
|
const factory = super.resolve(key)
|
||||||
@@ -60,6 +65,77 @@ export class ScopedContainer extends Container {
|
|||||||
return factory
|
return factory
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.parentContainer?.resolve(key)
|
if ( this.resolveParentScope ) {
|
||||||
|
return this.parentContainer.resolve(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a basic instantiable class as a standard Factory with this container.
|
||||||
|
* @param {Instantiable} dependency
|
||||||
|
*/
|
||||||
|
register(dependency: Instantiable<any>): this {
|
||||||
|
return this.withoutParentScopes(() => super.register(dependency))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the given function as a factory within the container.
|
||||||
|
* @param {string} name - unique name to identify the factory in the container
|
||||||
|
* @param {function} producer - factory to produce a value
|
||||||
|
*/
|
||||||
|
registerProducer(name: DependencyKey, producer: () => any): this {
|
||||||
|
return this.withoutParentScopes(() => super.registerProducer(name, producer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a basic instantiable class as a standard Factory with this container,
|
||||||
|
* identified by a string name rather than static class.
|
||||||
|
* @param {string} name - unique name to identify the factory in the container
|
||||||
|
* @param {Instantiable} dependency
|
||||||
|
*/
|
||||||
|
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||||
|
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a value as a singleton in the container. It will not be instantiated, but
|
||||||
|
* can be injected by its unique name.
|
||||||
|
* @param {string} key - unique name to identify the singleton in the container
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
registerSingleton<T>(key: DependencyKey, value: T): this {
|
||||||
|
return this.withoutParentScopes(() => super.registerSingleton(key, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a static class to the container along with its already-instantiated
|
||||||
|
* instance that will be used to resolve the class.
|
||||||
|
* @param staticClass
|
||||||
|
* @param instance
|
||||||
|
*/
|
||||||
|
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||||
|
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a given factory with the container.
|
||||||
|
* @param {AbstractFactory} factory
|
||||||
|
*/
|
||||||
|
registerFactory(factory: AbstractFactory<unknown>): this {
|
||||||
|
return this.withoutParentScopes(() => super.registerFactory(factory))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a closure on this container, disabling parent-resolution.
|
||||||
|
* Effectively, the closure will have access to this container as if
|
||||||
|
* it were NOT a scoped container, and only contained its factories.
|
||||||
|
* @param closure
|
||||||
|
*/
|
||||||
|
withoutParentScopes<T>(closure: () => T): T {
|
||||||
|
const oldResolveParentScope = this.resolveParentScope
|
||||||
|
this.resolveParentScope = false
|
||||||
|
const value: T = closure()
|
||||||
|
this.resolveParentScope = oldResolveParentScope
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||||
PropertyDependency,
|
PropertyDependency,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import {Container} from '../Container'
|
import {ContainerBlueprint} from '../ContainerBlueprint'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a collection of dependency requirements for the given target object.
|
* Get a collection of dependency requirements for the given target object.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,10 +153,22 @@ export const Singleton = (name?: string): ClassDecorator => {
|
|||||||
Injectable()(target)
|
Injectable()(target)
|
||||||
|
|
||||||
if ( name ) {
|
if ( name ) {
|
||||||
Container.getContainer().registerNamed(name, target)
|
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
|
||||||
} else {
|
} else {
|
||||||
Container.getContainer().register(target)
|
ContainerBlueprint.getContainerBlueprint().register(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a factory class directly with any created containers.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const FactoryProducer = (): ClassDecorator => {
|
||||||
|
return (target) => {
|
||||||
|
if ( isInstantiable(target) ) {
|
||||||
|
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from './factory/Factory'
|
|||||||
export * from './factory/NamedFactory'
|
export * from './factory/NamedFactory'
|
||||||
export * from './factory/SingletonFactory'
|
export * from './factory/SingletonFactory'
|
||||||
|
|
||||||
|
export * from './ContainerBlueprint'
|
||||||
export * from './Container'
|
export * from './Container'
|
||||||
export * from './ScopedContainer'
|
export * from './ScopedContainer'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
src/event/Event.ts
Normal file
13
src/event/Event.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import {Dispatchable} from './types'
|
||||||
|
import {Awaitable, JSONState} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class representing an event that may be fired.
|
||||||
|
*/
|
||||||
|
export abstract class Event implements Dispatchable {
|
||||||
|
|
||||||
|
|
||||||
|
abstract dehydrate(): Awaitable<JSONState>
|
||||||
|
|
||||||
|
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||||
|
}
|
||||||
53
src/event/EventBus.ts
Normal file
53
src/event/EventBus.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {Instantiable, Singleton, StaticClass} from '../di'
|
||||||
|
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
|
||||||
|
import {Awaitable, Collection, uuid4} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class EventBus implements Bus {
|
||||||
|
/**
|
||||||
|
* Collection of subscribers, by their events.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
||||||
|
|
||||||
|
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
||||||
|
const entry: EventSubscriberEntry<T> = {
|
||||||
|
id: uuid4(),
|
||||||
|
event,
|
||||||
|
subscriber,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscribers.push(entry)
|
||||||
|
return this.buildSubscription(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
|
||||||
|
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(event: Dispatchable): Promise<void> {
|
||||||
|
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
||||||
|
await this.subscribers.where('event', '=', eventClass)
|
||||||
|
.promiseMap(entry => entry.subscriber(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an EventSubscription object for the subscriber of the given ID.
|
||||||
|
* @param id
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected buildSubscription(id: string): EventSubscription {
|
||||||
|
let subscribed = true
|
||||||
|
return {
|
||||||
|
unsubscribe: (): Awaitable<void> => {
|
||||||
|
if ( subscribed ) {
|
||||||
|
this.subscribers = this.subscribers.where('id', '!=', id)
|
||||||
|
subscribed = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/event/PropagatingEventBus.ts
Normal file
28
src/event/PropagatingEventBus.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {EventBus} from './EventBus'
|
||||||
|
import {Collection} from '../util'
|
||||||
|
import {Bus, Dispatchable} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
||||||
|
* This bus also supports "propagating" events along to any other connected buses.
|
||||||
|
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
|
||||||
|
* level bus whose events still reach the global EventBus instance.
|
||||||
|
*/
|
||||||
|
export class PropagatingEventBus extends EventBus {
|
||||||
|
protected recipients: Collection<Bus> = new Collection<Bus>()
|
||||||
|
|
||||||
|
async dispatch(event: Dispatchable): Promise<void> {
|
||||||
|
await super.dispatch(event)
|
||||||
|
await this.recipients.promiseMap(bus => bus.dispatch(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the given bus to receive events fired on this bus.
|
||||||
|
* @param recipient
|
||||||
|
*/
|
||||||
|
connect(recipient: Bus): void {
|
||||||
|
if ( !this.recipients.includes(recipient) ) {
|
||||||
|
this.recipients.push(recipient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/event/types.ts
Normal file
47
src/event/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {Awaitable, Rehydratable} from '../util'
|
||||||
|
import {Instantiable, StaticClass} from '../di'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closure that should be executed with the given event is fired.
|
||||||
|
*/
|
||||||
|
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object used to track event subscriptions internally.
|
||||||
|
*/
|
||||||
|
export interface EventSubscriberEntry<T extends Dispatchable> {
|
||||||
|
/** Globally unique ID of this subscription. */
|
||||||
|
id: string
|
||||||
|
|
||||||
|
/** The event class subscribed to. */
|
||||||
|
event: StaticClass<T, Instantiable<T>>
|
||||||
|
|
||||||
|
/** The closure to execute when the event is fired. */
|
||||||
|
subscriber: EventSubscriber<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object returned upon subscription, used to unsubscribe.
|
||||||
|
*/
|
||||||
|
export interface EventSubscription {
|
||||||
|
/**
|
||||||
|
* Unsubscribe the associated listener from the event bus.
|
||||||
|
*/
|
||||||
|
unsubscribe(): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An instance of something that can be fired on an event bus.
|
||||||
|
*/
|
||||||
|
export interface Dispatchable extends Rehydratable {
|
||||||
|
shouldQueue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event-driven bus that manages subscribers and dispatched items.
|
||||||
|
*/
|
||||||
|
export interface Bus {
|
||||||
|
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
||||||
|
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
||||||
|
dispatch(event: Dispatchable): Awaitable<void>
|
||||||
|
}
|
||||||
@@ -1,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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly status: HTTPStatus = 500,
|
public readonly status: HTTPStatus = 500,
|
||||||
public readonly message: string = '',
|
public readonly message: string = '',
|
||||||
|
context?: {[key: string]: any},
|
||||||
) {
|
) {
|
||||||
super('HTTP ERROR')
|
super('HTTP ERROR', context)
|
||||||
this.message = message || HTTPMessage[status]
|
this.message = message || HTTPMessage[status]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {Logging} from '../../service/Logging'
|
|||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
import {Request} from '../lifecycle/Request'
|
import {Request} from '../lifecycle/Request'
|
||||||
import {error} from '../response/ErrorResponseFactory'
|
import {error} from '../response/ErrorResponseFactory'
|
||||||
|
import {HTTPError} from '../HTTPError'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for fluently registering kernel modules into the kernel.
|
* Interface for fluently registering kernel modules into the kernel.
|
||||||
@@ -105,7 +106,8 @@ export class HTTPKernel extends AppClass {
|
|||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logging.error(e)
|
this.logging.error(e)
|
||||||
await error(e).status(HTTPStatus.INTERNAL_SERVER_ERROR)
|
const status = (e instanceof HTTPError && e.status) ? e.status : HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
await error(e).status(status)
|
||||||
.write(request)
|
.write(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
|
|||||||
import {plaintext} from '../../response/StringResponseFactory'
|
import {plaintext} from '../../response/StringResponseFactory'
|
||||||
import {ResponseFactory} from '../../response/ResponseFactory'
|
import {ResponseFactory} from '../../response/ResponseFactory'
|
||||||
import {json} from '../../response/JSONResponseFactory'
|
import {json} from '../../response/JSONResponseFactory'
|
||||||
|
import {UniversalPath} from '../../../util'
|
||||||
|
import {file} from '../../response/FileResponseFactory'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
|
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
|
||||||
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
|
|||||||
|
|
||||||
if ( object instanceof ResponseFactory ) {
|
if ( object instanceof ResponseFactory ) {
|
||||||
await object.write(request)
|
await object.write(request)
|
||||||
|
} else if ( object instanceof UniversalPath ) {
|
||||||
|
await file(object).write(request)
|
||||||
} else if ( typeof object !== 'undefined' ) {
|
} else if ( typeof object !== 'undefined' ) {
|
||||||
await json(object).write(request)
|
await json(object).write(request)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
28
src/http/kernel/module/InjectRequestEventBusHTTPModule.ts
Normal file
28
src/http/kernel/module/InjectRequestEventBusHTTPModule.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {HTTPKernel} from '../HTTPKernel'
|
||||||
|
import {Request} from '../../lifecycle/Request'
|
||||||
|
import {EventBus} from '../../../event/EventBus'
|
||||||
|
import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP kernel module that creates a request-specific event bus
|
||||||
|
* and injects it into the request container.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
|
||||||
|
@Inject()
|
||||||
|
protected bus!: EventBus
|
||||||
|
|
||||||
|
public static register(kernel: HTTPKernel): void {
|
||||||
|
kernel.register(this).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async apply(request: Request): Promise<Request> {
|
||||||
|
const bus = <PropagatingEventBus> this.make(PropagatingEventBus)
|
||||||
|
bus.connect(this.bus)
|
||||||
|
|
||||||
|
request.purge(EventBus).registerProducer(EventBus, () => bus)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
|
|||||||
const route = this.routing.match(request.method, request.path)
|
const route = this.routing.match(request.method, request.path)
|
||||||
if ( route ) {
|
if ( route ) {
|
||||||
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
|
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
|
||||||
const activated = new ActivatedRoute(route, request.path)
|
const activated = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
|
||||||
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
|
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
|
||||||
} else {
|
} else {
|
||||||
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
|
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
|
|||||||
})
|
})
|
||||||
|
|
||||||
busboy.on('finish', () => {
|
busboy.on('finish', () => {
|
||||||
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
|
|
||||||
res()
|
res()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
|
|||||||
public async apply(request: Request): Promise<Request> {
|
public async apply(request: Request): Promise<Request> {
|
||||||
if ( !this.config.get('server.poweredBy.hide', false) ) {
|
if ( !this.config.get('server.poweredBy.hide', false) ) {
|
||||||
request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo'))
|
request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo'))
|
||||||
|
request.response.setHeader('Server', this.config.get('server.poweredBy.header', 'Extollo'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
return 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 ?? '/'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {Request} from './Request'
|
|||||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
|
||||||
import {ServerResponse} from 'http'
|
import {ServerResponse} from 'http'
|
||||||
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
|
||||||
|
import {Readable} from 'stream'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error thrown when the server tries to re-send headers after they have been sent once.
|
* Error thrown when the server tries to re-send headers after they have been sent once.
|
||||||
@@ -47,7 +48,7 @@ export class Response {
|
|||||||
private isBlockingWriteback = false
|
private isBlockingWriteback = false
|
||||||
|
|
||||||
/** The body contents that should be written to the response. */
|
/** The body contents that should be written to the response. */
|
||||||
public body = ''
|
public body: string | Buffer | Uint8Array | Readable = ''
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Behavior subject fired right before the response content is written.
|
* Behavior subject fired right before the response content is written.
|
||||||
@@ -192,11 +193,21 @@ export class Response {
|
|||||||
* Write the headers and specified data to the client.
|
* Write the headers and specified data to the client.
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
public async write(data: unknown): Promise<void> {
|
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
|
||||||
return new Promise<void>((res, rej) => {
|
return new Promise<void>((res, rej) => {
|
||||||
if ( !this.sentHeaders ) {
|
if ( !this.sentHeaders ) {
|
||||||
this.sendHeaders()
|
this.sendHeaders()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( data instanceof Readable ) {
|
||||||
|
data.pipe(this.serverResponse)
|
||||||
|
.on('finish', () => {
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
.on('error', error => {
|
||||||
|
rej(error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
this.serverResponse.write(data, error => {
|
this.serverResponse.write(data, error => {
|
||||||
if ( error ) {
|
if ( error ) {
|
||||||
rej(error)
|
rej(error)
|
||||||
@@ -204,6 +215,7 @@ export class Response {
|
|||||||
res()
|
res()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,9 +224,17 @@ export class Response {
|
|||||||
*/
|
*/
|
||||||
public async send(): Promise<void> {
|
public async send(): Promise<void> {
|
||||||
await this.sending$.next(this)
|
await this.sending$.next(this)
|
||||||
|
|
||||||
|
if ( !(this.body instanceof Readable) ) {
|
||||||
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
this.setHeader('Content-Length', String(this.body?.length ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setHeader('Date', (new Date()).toUTCString())
|
||||||
|
this.setHeader('Permissions-Policy', 'interest-cohort=()')
|
||||||
|
|
||||||
await this.write(this.body ?? '')
|
await this.write(this.body ?? '')
|
||||||
this.end()
|
this.end()
|
||||||
|
|
||||||
await this.sent$.next(this)
|
await this.sent$.next(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/http/response/FileResponseFactory.ts
Normal file
38
src/http/response/FileResponseFactory.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {ResponseFactory} from './ResponseFactory'
|
||||||
|
import {Request} from '../lifecycle/Request'
|
||||||
|
import {ErrorWithContext, UniversalPath} from '../../util'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that creates a FileResponseFactory for the given path.
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export function file(path: UniversalPath): FileResponseFactory {
|
||||||
|
return new FileResponseFactory(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP response factory that sends a file referenced by a given UniversalPath.
|
||||||
|
*/
|
||||||
|
export class FileResponseFactory extends ResponseFactory {
|
||||||
|
constructor(
|
||||||
|
/** The file to be sent. */
|
||||||
|
public readonly path: UniversalPath,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(request: Request): Promise<Request> {
|
||||||
|
if ( !(await this.path.isFile()) ) {
|
||||||
|
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
|
||||||
|
path: this.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
request.make<Logging>(Logging).debug(`Setting Content-Type of ${this.path} to ${this.path.contentType}...`)
|
||||||
|
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
|
||||||
|
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
|
||||||
|
request.response.body = await this.path.readStream()
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/http/response/RedirectResponseFactory.ts
Normal file
31
src/http/response/RedirectResponseFactory.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {ResponseFactory} from './ResponseFactory'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {Request} from '../lifecycle/Request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a new RedirectResponseFactory to the given destination.
|
||||||
|
* @param destination
|
||||||
|
*/
|
||||||
|
export function redirect(destination: string): RedirectResponseFactory {
|
||||||
|
return new RedirectResponseFactory(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response factory that sends an HTTP redirect to the given destination.
|
||||||
|
*/
|
||||||
|
export class RedirectResponseFactory extends ResponseFactory {
|
||||||
|
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** THe URL where the client should redirect to. */
|
||||||
|
public readonly destination: string,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(request: Request): Promise<Request> {
|
||||||
|
request = await super.write(request)
|
||||||
|
request.response.setHeader('Location', this.destination)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/http/response/RouteResponseFactory.ts
Normal file
40
src/http/response/RouteResponseFactory.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {ResponseFactory} from './ResponseFactory'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {Request} from '../lifecycle/Request'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a new RouteResponseFactory to the given destination.
|
||||||
|
* @param nameOrPath
|
||||||
|
*/
|
||||||
|
export function route(nameOrPath: string): RouteResponseFactory {
|
||||||
|
return new RouteResponseFactory(nameOrPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response factory that sends an HTTP redirect to the given destination.
|
||||||
|
*/
|
||||||
|
export class RouteResponseFactory extends ResponseFactory {
|
||||||
|
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** The alias or path of the route to redirect to. */
|
||||||
|
public readonly nameOrPath: string,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(request: Request): Promise<Request> {
|
||||||
|
const routing = <Routing> request.make(Routing)
|
||||||
|
request = await super.write(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const routePath = routing.getNamedPath(this.nameOrPath)
|
||||||
|
request.response.setHeader('Location', routePath.toRemote)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
request.response.setHeader('Location', routing.getAppUrl().concat(this.nameOrPath).toRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {Request} from '../lifecycle/Request'
|
|||||||
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
|
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
|
||||||
* @param destination
|
* @param destination
|
||||||
*/
|
*/
|
||||||
export function redirect(destination: string): TemporaryRedirectResponseFactory {
|
export function temporary(destination: string): TemporaryRedirectResponseFactory {
|
||||||
return new TemporaryRedirectResponseFactory(destination)
|
return new TemporaryRedirectResponseFactory(destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {ErrorWithContext} from '../../util'
|
import {ErrorWithContext} from '../../util'
|
||||||
import {ResolvedRouteHandler, Route} from './Route'
|
import {ResolvedRouteHandler, Route} from './Route'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class representing a resolved route that a request is mounted to.
|
* Class representing a resolved route that a request is mounted to.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class ActivatedRoute {
|
export class ActivatedRoute {
|
||||||
/**
|
/**
|
||||||
* The parsed params from the route definition.
|
* The parsed params from the route definition.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -87,11 +87,15 @@ export class Route extends AppClass {
|
|||||||
for ( const group of stack ) {
|
for ( const group of stack ) {
|
||||||
route.prepend(group.prefix)
|
route.prepend(group.prefix)
|
||||||
group.getGroupMiddlewareDefinitions()
|
group.getGroupMiddlewareDefinitions()
|
||||||
.each(def => route.prependMiddleware(def))
|
.where('stage', '=', 'pre')
|
||||||
|
.each(def => {
|
||||||
|
route.prependMiddleware(def)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for ( const group of this.compiledGroupStack ) {
|
for ( const group of this.compiledGroupStack ) {
|
||||||
group.getGroupMiddlewareDefinitions()
|
group.getGroupMiddlewareDefinitions()
|
||||||
|
.where('stage', '=', 'post')
|
||||||
.each(def => route.appendMiddleware(def))
|
.each(def => route.appendMiddleware(def))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +215,9 @@ export class Route extends AppClass {
|
|||||||
/** Pre-compiled route handler for the main route handler for this route. */
|
/** Pre-compiled route handler for the main route handler for this route. */
|
||||||
protected compiledPostflight?: ResolvedRouteHandler[]
|
protected compiledPostflight?: ResolvedRouteHandler[]
|
||||||
|
|
||||||
|
/** Programmatic aliases of this route. */
|
||||||
|
public aliases: string[] = []
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
/** The HTTP method(s) that this route listens on. */
|
/** The HTTP method(s) that this route listens on. */
|
||||||
protected method: HTTPMethod | HTTPMethod[],
|
protected method: HTTPMethod | HTTPMethod[],
|
||||||
@@ -224,6 +231,43 @@ export class Route extends AppClass {
|
|||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a programmatic name for this route.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public alias(name: string): this {
|
||||||
|
this.aliases.push(name)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the string-form of the route.
|
||||||
|
*/
|
||||||
|
public getRoute(): string {
|
||||||
|
return this.route
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the string-form method of the route.
|
||||||
|
*/
|
||||||
|
public getMethod(): HTTPMethod | HTTPMethod[] {
|
||||||
|
return this.method
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collection of applied middlewares.
|
||||||
|
*/
|
||||||
|
public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
|
||||||
|
return this.middlewares.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the string-form of the route handler.
|
||||||
|
*/
|
||||||
|
public getDisplayableHandler(): string {
|
||||||
|
return typeof this.handler === 'string' ? this.handler : '(anonymous function)'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this route matches the given HTTP verb and request path.
|
* Returns true if this route matches the given HTTP verb and request path.
|
||||||
* @param method
|
* @param method
|
||||||
|
|||||||
188
src/http/servers/static.ts
Normal file
188
src/http/servers/static.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {Request} from '../lifecycle/Request'
|
||||||
|
import {ActivatedRoute} from '../routing/ActivatedRoute'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
|
||||||
|
import {Application} from '../../lifecycle/Application'
|
||||||
|
import {HTTPError} from '../HTTPError'
|
||||||
|
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
|
||||||
|
import {redirect} from '../response/RedirectResponseFactory'
|
||||||
|
import {file} from '../response/FileResponseFactory'
|
||||||
|
import {RouteHandler} from '../routing/Route'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the behavior of the static server.
|
||||||
|
*/
|
||||||
|
export interface StaticServerOptions {
|
||||||
|
/** If true, browsing to a directory route will show the directory listing page. */
|
||||||
|
directoryListing?: boolean
|
||||||
|
|
||||||
|
/** The path to the directory whose files should be served. */
|
||||||
|
basePath?: string | string[] | UniversalPath
|
||||||
|
|
||||||
|
/** If specified, only files with these extensions will be served. */
|
||||||
|
allowedExtensions?: string[]
|
||||||
|
|
||||||
|
/** If specified, files with these extensions will not be served. */
|
||||||
|
excludedExtensions?: string[]
|
||||||
|
|
||||||
|
/** If a file with this name exists in a directory, it will be served. */
|
||||||
|
indexFile?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPError class thrown by the static server.
|
||||||
|
*/
|
||||||
|
export class StaticServerHTTPError extends HTTPError {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the response factory that shows the directory listing.
|
||||||
|
* @param dirname
|
||||||
|
* @param dirPath
|
||||||
|
*/
|
||||||
|
async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise<ViewResponseFactory> {
|
||||||
|
return view('@extollo:static:dirlist', {
|
||||||
|
dirname,
|
||||||
|
contents: (await (await dirPath.list())
|
||||||
|
.promiseMap(async path => {
|
||||||
|
const isDirectory = await path.isDirectory()
|
||||||
|
return {
|
||||||
|
isDirectory,
|
||||||
|
name: path.toBase,
|
||||||
|
size: isDirectory ? '-' : await path.sizeForHumans(),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.sortBy(row => {
|
||||||
|
return `${row.isDirectory ? 0 : 1}${row.name}`
|
||||||
|
})
|
||||||
|
.all(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given file path has an extension that is allowed by
|
||||||
|
* the static server options.
|
||||||
|
* @param filePath
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
!options.allowedExtensions
|
||||||
|
|| options.allowedExtensions.includes(filePath.ext)
|
||||||
|
)
|
||||||
|
&& (
|
||||||
|
!options.excludedExtensions
|
||||||
|
|| !options.excludedExtensions.includes(filePath.ext)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured base path into a universal path.
|
||||||
|
* Defaults to `{app path}/resources/static` if none provided.
|
||||||
|
* @param appPath
|
||||||
|
* @param basePath
|
||||||
|
*/
|
||||||
|
function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath {
|
||||||
|
if ( basePath instanceof UniversalPath ) {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !basePath ) {
|
||||||
|
return appPath.concat('resources', 'static')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( Array.isArray(basePath) ) {
|
||||||
|
return appPath.concat(...basePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( basePath.startsWith('/') ) {
|
||||||
|
return universalPath(basePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return appPath.concat(basePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a route handler that serves a directory as static files.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
|
||||||
|
return async (request: Request) => {
|
||||||
|
const config = <Config> request.make(Config)
|
||||||
|
const route = <ActivatedRoute> request.make(ActivatedRoute)
|
||||||
|
const app = <Application> request.make(Application)
|
||||||
|
|
||||||
|
const staticConfig = config.get('server.builtIns.static', {})
|
||||||
|
const mergedOptions = {
|
||||||
|
...staticConfig,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the path to the resource on the filesystem
|
||||||
|
const basePath = getBasePath(app.appPath(), mergedOptions.basePath)
|
||||||
|
const filePath = basePath.concat(...Collection.normalize<string>(route.params[0]))
|
||||||
|
|
||||||
|
// If the resolved path is outside of the base path, fail out
|
||||||
|
if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) {
|
||||||
|
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||||
|
basePath: basePath.toString(),
|
||||||
|
filePath: filePath.toString(),
|
||||||
|
route: route.path,
|
||||||
|
reason: 'Resolved file is not a child of the base path.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resolved file is an invalid file extension, fail out
|
||||||
|
if ( !isValidFileExtension(filePath, mergedOptions) ) {
|
||||||
|
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||||
|
basePath: basePath.toString(),
|
||||||
|
filePath: filePath.toString(),
|
||||||
|
route: route.path,
|
||||||
|
allowedExtensions: mergedOptions.allowedExtensions,
|
||||||
|
excludedExtensions: mergedOptions.excludedExtensions,
|
||||||
|
reason: 'Resolved file is not an allowed extension type',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resolved file does not exist on the filesystem, fail out
|
||||||
|
if ( !(await filePath.exists()) ) {
|
||||||
|
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, {
|
||||||
|
basePath: basePath.toString(),
|
||||||
|
filePath: filePath.toString(),
|
||||||
|
route: route.path,
|
||||||
|
reason: 'Resolved file does not exist on the filesystem',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resolved path is a directory, send the directory listing response
|
||||||
|
if ( await filePath.isDirectory() ) {
|
||||||
|
if ( options.indexFile ) {
|
||||||
|
const indexFile = filePath.concat(options.indexFile)
|
||||||
|
if ( await indexFile.exists() ) {
|
||||||
|
return file(indexFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !options.directoryListing ) {
|
||||||
|
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
|
||||||
|
basePath: basePath.toString(),
|
||||||
|
filePath: filePath.toString(),
|
||||||
|
route: route.path,
|
||||||
|
reason: 'Path is a directory, and directory listing is disabled',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !route.path.endsWith('/') ) {
|
||||||
|
return redirect(`${route.path}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDirectoryListingResponse(route.path, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just send the file as the response body
|
||||||
|
return file(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,4 +93,12 @@ export class MemorySession extends Session {
|
|||||||
|
|
||||||
this.data[key] = value
|
this.data[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public forget(key: string): void {
|
||||||
|
if ( !this.data ) {
|
||||||
|
throw new SessionNotLoadedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.data[key]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,4 +57,7 @@ export abstract class Session {
|
|||||||
|
|
||||||
/** Set a value in the session by key. */
|
/** Set a value in the session by key. */
|
||||||
public abstract set(key: string, value: unknown): void
|
public abstract set(key: string, value: unknown): void
|
||||||
|
|
||||||
|
/** Remove a key from the session data. */
|
||||||
|
public abstract forget(key: string): void
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/index.ts
21
src/index.ts
@@ -1,6 +1,12 @@
|
|||||||
export * from './util'
|
export * from './util'
|
||||||
|
export * from './lib'
|
||||||
export * from './di'
|
export * from './di'
|
||||||
|
|
||||||
|
export * from './event/types'
|
||||||
|
export * from './event/Event'
|
||||||
|
export * from './event/EventBus'
|
||||||
|
export * from './event/PropagatingEventBus'
|
||||||
|
|
||||||
export * from './service/Logging'
|
export * from './service/Logging'
|
||||||
|
|
||||||
export * from './lifecycle/RunLevelErrorHandler'
|
export * from './lifecycle/RunLevelErrorHandler'
|
||||||
@@ -36,7 +42,10 @@ export * from './http/response/JSONResponseFactory'
|
|||||||
export * from './http/response/ResponseFactory'
|
export * from './http/response/ResponseFactory'
|
||||||
export * from './http/response/StringResponseFactory'
|
export * from './http/response/StringResponseFactory'
|
||||||
export * from './http/response/TemporaryRedirectResponseFactory'
|
export * from './http/response/TemporaryRedirectResponseFactory'
|
||||||
|
export * from './http/response/RedirectResponseFactory'
|
||||||
export * from './http/response/ViewResponseFactory'
|
export * from './http/response/ViewResponseFactory'
|
||||||
|
export * from './http/response/FileResponseFactory'
|
||||||
|
export * from './http/response/RouteResponseFactory'
|
||||||
|
|
||||||
export * from './http/routing/ActivatedRoute'
|
export * from './http/routing/ActivatedRoute'
|
||||||
export * from './http/routing/Route'
|
export * from './http/routing/Route'
|
||||||
@@ -51,6 +60,11 @@ export * from './http/session/MemorySession'
|
|||||||
|
|
||||||
export * from './http/Controller'
|
export * from './http/Controller'
|
||||||
|
|
||||||
|
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'
|
||||||
@@ -63,8 +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/queue/Queue'
|
||||||
|
|
||||||
|
export * from './service/Queueables'
|
||||||
|
|
||||||
export * from './views/ViewEngine'
|
export * from './views/ViewEngine'
|
||||||
export * from './views/ViewEngineFactory'
|
export * from './views/ViewEngineFactory'
|
||||||
@@ -74,3 +94,4 @@ export * from './cli'
|
|||||||
export * from './i18n'
|
export * from './i18n'
|
||||||
export * from './forms'
|
export * from './forms'
|
||||||
export * from './orm'
|
export * from './orm'
|
||||||
|
export * from './auth'
|
||||||
|
|||||||
8
src/lib.ts
Normal file
8
src/lib.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {UniversalPath} from './util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the root of the @extollo/lib package.
|
||||||
|
*/
|
||||||
|
export function lib(): UniversalPath {
|
||||||
|
return new UniversalPath(__dirname)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Application} from './Application'
|
import {Application} from './Application'
|
||||||
import {Container, DependencyKey} from '../di'
|
import {Container, DependencyKey, Injectable} from '../di'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base type for a class that supports binding methods by string.
|
* Base type for a class that supports binding methods by string.
|
||||||
@@ -25,12 +25,11 @@ export function isBindable(what: unknown): what is Bindable {
|
|||||||
/**
|
/**
|
||||||
* Base for classes that gives access to the global application and container.
|
* Base for classes that gives access to the global application and container.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class AppClass {
|
export class AppClass {
|
||||||
/** The global application instance. */
|
/** The global application instance. */
|
||||||
private readonly appClassApplication!: Application;
|
private get appClassApplication(): Application {
|
||||||
|
return Application.getApplication()
|
||||||
constructor() {
|
|
||||||
this.appClassApplication = Application.getApplication()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the global Application. */
|
/** Get the global Application. */
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ 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'
|
||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import {CacheFactory} from '../support/cache/CacheFactory'
|
import {CacheFactory} from '../support/cache/CacheFactory'
|
||||||
|
import {FileLogger} from '../util/logging/FileLogger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function that resolves and infers environment variable values.
|
* Helper function that resolves and infers environment variable values.
|
||||||
@@ -48,10 +48,16 @@ export function appPath(...parts: PathLike[]): UniversalPath {
|
|||||||
* The main application container.
|
* The main application container.
|
||||||
*/
|
*/
|
||||||
export class Application extends Container {
|
export class Application extends Container {
|
||||||
|
public static readonly NODE_MODULES_INJECTION = 'extollo/npm'
|
||||||
|
|
||||||
|
public static get NODE_MODULES_PROVIDER(): string {
|
||||||
|
return process.env.EXTOLLO_NPM || 'pnpm'
|
||||||
|
}
|
||||||
|
|
||||||
public static getContainer(): Container {
|
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())
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
@@ -67,13 +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())
|
||||||
globalRegistry.setGlobal('extollo/injector', app)
|
globalRegistry.setGlobal('extollo/injector', app)
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
@@ -195,6 +200,7 @@ export class Application extends Container {
|
|||||||
this.setupLogging()
|
this.setupLogging()
|
||||||
|
|
||||||
this.registerFactory(new CacheFactory()) // FIXME move this somewhere else?
|
this.registerFactory(new CacheFactory()) // FIXME move this somewhere else?
|
||||||
|
this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER)
|
||||||
|
|
||||||
this.make<Logging>(Logging).debug(`Application root: ${this.baseDir}`)
|
this.make<Logging>(Logging).debug(`Application root: ${this.baseDir}`)
|
||||||
}
|
}
|
||||||
@@ -208,6 +214,12 @@ export class Application extends Container {
|
|||||||
const logging: Logging = this.make<Logging>(Logging)
|
const logging: Logging = this.make<Logging>(Logging)
|
||||||
|
|
||||||
logging.registerLogger(standard)
|
logging.registerLogger(standard)
|
||||||
|
|
||||||
|
if ( this.env('EXTOLLO_LOGGING_ENABLE_FILE') ) {
|
||||||
|
const file: FileLogger = this.make<FileLogger>(FileLogger)
|
||||||
|
logging.registerLogger(file)
|
||||||
|
}
|
||||||
|
|
||||||
logging.verbose('Attempting to load logging level from the environment...')
|
logging.verbose('Attempting to load logging level from the environment...')
|
||||||
|
|
||||||
const envLevel = this.env('EXTOLLO_LOGGING_LEVEL')
|
const envLevel = this.env('EXTOLLO_LOGGING_LEVEL')
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class DatabaseService extends AppClass {
|
|||||||
* Get a connection instance by its name. Throws if none exists.
|
* Get a connection instance by its name. Throws if none exists.
|
||||||
* @param name
|
* @param name
|
||||||
*/
|
*/
|
||||||
get(name: string): Connection {
|
get(name = 'default'): Connection {
|
||||||
if ( !this.has(name) ) {
|
if ( !this.has(name) ) {
|
||||||
throw new ErrorWithContext(`No such connection is registered: ${name}`)
|
throw new ErrorWithContext(`No such connection is registered: ${name}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Inject} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {DatabaseService} from '../DatabaseService'
|
import {DatabaseService} from '../DatabaseService'
|
||||||
import {
|
import {
|
||||||
Constraint, ConstraintConnectionOperator,
|
Constraint, ConstraintConnectionOperator,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
SpecifiedField,
|
SpecifiedField,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import {Connection} from '../connection/Connection'
|
import {Connection} from '../connection/Connection'
|
||||||
import {deepCopy, ErrorWithContext} from '../../util'
|
import {deepCopy, ErrorWithContext, Maybe} from '../../util'
|
||||||
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
|
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
|
||||||
import {ResultCollection} from './result/ResultCollection'
|
import {ResultCollection} from './result/ResultCollection'
|
||||||
import {AbstractResultIterable} from './result/AbstractResultIterable'
|
import {AbstractResultIterable} from './result/AbstractResultIterable'
|
||||||
@@ -24,6 +24,7 @@ export type ConstraintGroupClosure<T> = (group: AbstractBuilder<T>) => any
|
|||||||
* A base class that facilitates building database queries using a fluent interface.
|
* A base class that facilitates building database queries using a fluent interface.
|
||||||
* This can be specialized by child-classes to yield query results of the given type `T`.
|
* This can be specialized by child-classes to yield query results of the given type `T`.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export abstract class AbstractBuilder<T> extends AppClass {
|
export abstract class AbstractBuilder<T> extends AppClass {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly databaseService!: DatabaseService
|
protected readonly databaseService!: DatabaseService
|
||||||
@@ -55,6 +56,9 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
/** The connection on which the query should be executed. */
|
/** The connection on which the query should be executed. */
|
||||||
protected registeredConnection?: Connection
|
protected registeredConnection?: Connection
|
||||||
|
|
||||||
|
/** Raw SQL to use instead. Overrides builder methods. */
|
||||||
|
protected rawSql?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new, empty, instance of the current builder.
|
* Create a new, empty, instance of the current builder.
|
||||||
*/
|
*/
|
||||||
@@ -80,6 +84,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
bldr.registeredGroupings = deepCopy(this.registeredGroupings)
|
bldr.registeredGroupings = deepCopy(this.registeredGroupings)
|
||||||
bldr.registeredOrders = deepCopy(this.registeredOrders)
|
bldr.registeredOrders = deepCopy(this.registeredOrders)
|
||||||
bldr.registeredConnection = this.registeredConnection
|
bldr.registeredConnection = this.registeredConnection
|
||||||
|
bldr.rawSql = this.rawSql
|
||||||
|
|
||||||
return bldr
|
return bldr
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,11 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
return deepCopy(this.registeredOrders)
|
return deepCopy(this.registeredOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the raw SQL overriding the builder methods, if it exists. */
|
||||||
|
public get appliedRawSql(): Maybe<string> {
|
||||||
|
return this.rawSql
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the source table for this query. */
|
/** Get the source table for this query. */
|
||||||
public get querySource(): QuerySource | undefined {
|
public get querySource(): QuerySource | undefined {
|
||||||
if ( this.source ) {
|
if ( this.source ) {
|
||||||
@@ -555,6 +565,21 @@ export abstract class AbstractBuilder<T> extends AppClass {
|
|||||||
return Boolean(result.rows.first())
|
return Boolean(result.rows.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the query manually. Overrides any builder methods.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* (new Builder())
|
||||||
|
* .raw('SELECT NOW() AS example_column')
|
||||||
|
* .get()
|
||||||
|
* ```
|
||||||
|
* @param sql
|
||||||
|
*/
|
||||||
|
raw(sql: string): this {
|
||||||
|
this.rawSql = sql
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
|
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
|
||||||
* @param preop
|
* @param preop
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ErrorWithContext} from '../../util'
|
import {ErrorWithContext} from '../../util'
|
||||||
import {Container} from '../../di'
|
import {Container, Injectable} from '../../di'
|
||||||
import {ResultIterable} from './result/ResultIterable'
|
import {ResultIterable} from './result/ResultIterable'
|
||||||
import {QueryRow} from '../types'
|
import {QueryRow} from '../types'
|
||||||
import {AbstractBuilder} from './AbstractBuilder'
|
import {AbstractBuilder} from './AbstractBuilder'
|
||||||
@@ -8,6 +8,7 @@ import {AbstractResultIterable} from './result/AbstractResultIterable'
|
|||||||
/**
|
/**
|
||||||
* Implementation of the abstract builder class that returns simple QueryRow objects.
|
* Implementation of the abstract builder class that returns simple QueryRow objects.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export class Builder extends AbstractBuilder<QueryRow> {
|
export class Builder extends AbstractBuilder<QueryRow> {
|
||||||
public getNewInstance(): AbstractBuilder<QueryRow> {
|
public getNewInstance(): AbstractBuilder<QueryRow> {
|
||||||
return Container.getContainer().make<Builder>(Builder)
|
return Container.getContainer().make<Builder>(Builder)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
|
||||||
|
import {Schema} from '../schema/Schema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error thrown when a connection is used before it is ready.
|
* Error thrown when a connection is used before it is ready.
|
||||||
@@ -18,7 +22,10 @@ export class ConnectionNotReadyError extends ErrorWithContext {
|
|||||||
* Abstract base class for database connections.
|
* Abstract base class for database connections.
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
export abstract class Connection extends AppClass {
|
export abstract class Connection extends AppClass {
|
||||||
|
@Inject()
|
||||||
|
protected bus!: EventBus
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
/**
|
/**
|
||||||
@@ -55,13 +62,26 @@ export abstract class Connection extends AppClass {
|
|||||||
*/
|
*/
|
||||||
public abstract close(): Promise<void>
|
public abstract close(): Promise<void>
|
||||||
|
|
||||||
// public abstract databases(): Promise<Collection<Database>>
|
/**
|
||||||
|
* Get a Schema on this connection.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public abstract schema(name?: string): Schema
|
||||||
|
|
||||||
// public abstract database(name: string): Promise<Database | undefined>
|
/**
|
||||||
|
* 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>
|
||||||
|
|
||||||
// public abstract database_as_schema(name: string): Promise<Database>
|
/**
|
||||||
|
* Fire a QueryExecutedEvent for the given query string.
|
||||||
// public abstract tables(database_name: string): Promise<Collection<Table>>
|
* @param query
|
||||||
|
* @protected
|
||||||
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
|
*/
|
||||||
|
protected async queryExecuted(query: string): Promise<void> {
|
||||||
|
const event = new QueryExecutedEvent(this.name, this, query)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ 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'
|
||||||
|
import {Schema} from '../schema/Schema'
|
||||||
|
import {PostgresSchema} from '../schema/PostgresSchema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type interface representing the config for a PostgreSQL connection.
|
* Type interface representing the config for a PostgreSQL connection.
|
||||||
@@ -54,6 +56,7 @@ export class PostgresConnection extends Connection {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.client.query(query)
|
const result = await this.client.query(query)
|
||||||
|
await this.queryExecuted(query)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: collect(result.rows),
|
rows: collect(result.rows),
|
||||||
@@ -66,4 +69,19 @@ export class PostgresConnection extends Connection {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return new PostgresSchema(this, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/orm/connection/event/QueryExecutedEvent.ts
Normal file
67
src/orm/connection/event/QueryExecutedEvent.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {Event} from '../../../event/Event'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {InvalidJSONStateError, JSONState} from '../../../util'
|
||||||
|
import {Connection} from '../Connection'
|
||||||
|
import {DatabaseService} from '../../DatabaseService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a query is executed.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class QueryExecutedEvent extends Event {
|
||||||
|
@Inject()
|
||||||
|
protected database!: DatabaseService
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the connection where the query was executed.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
public connectionName!: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection where the query was executed.
|
||||||
|
*/
|
||||||
|
public connection!: Connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query that was executed.
|
||||||
|
*/
|
||||||
|
public query!: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
connectionName?: string,
|
||||||
|
connection?: Connection,
|
||||||
|
query?: string,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
if ( connectionName ) {
|
||||||
|
this.connectionName = connectionName
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( connection ) {
|
||||||
|
this.connection = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( query ) {
|
||||||
|
this.query = query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return {
|
||||||
|
connectionName: this.connectionName,
|
||||||
|
query: this.query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: JSONState): void {
|
||||||
|
if ( !state.connectionName || !state.query ) {
|
||||||
|
throw new InvalidJSONStateError('Missing connectionName or query from QueryExecutedEvent state.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.query = String(state.query)
|
||||||
|
this.connectionName = String(state.connectionName)
|
||||||
|
this.connection = this.database.get(this.connectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
|
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
|
||||||
import {Constraint, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
|
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
|
||||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||||
|
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
||||||
|
import {ErrorWithContext, Maybe} from '../../util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implementation of the SQLDialect specific to PostgreSQL.
|
* An implementation of the SQLDialect specific to PostgreSQL.
|
||||||
|
* @todo joins
|
||||||
|
* @todo sub-selects
|
||||||
*/
|
*/
|
||||||
export class PostgreSQLDialect extends SQLDialect {
|
export class PostgreSQLDialect extends SQLDialect {
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
`${pad(value.getSeconds())}`,
|
`${pad(value.getSeconds())}`,
|
||||||
]
|
]
|
||||||
|
|
||||||
return new QuerySafeValue(value, `${y}-${m}-${d} ${h}:${i}:${s}`)
|
return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`)
|
||||||
} else if ( !isNaN(Number(value)) ) {
|
} else if ( !isNaN(Number(value)) ) {
|
||||||
return new QuerySafeValue(value, String(Number(value)))
|
return new QuerySafeValue(value, String(Number(value)))
|
||||||
} else if ( value === null || typeof value === 'undefined' ) {
|
} else if ( value === null || typeof value === 'undefined' ) {
|
||||||
@@ -55,7 +59,7 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
'FROM (',
|
'FROM (',
|
||||||
...query.split('\n').map(x => ` ${x}`),
|
...query.split('\n').map(x => ` ${x}`),
|
||||||
') AS extollo_target_query',
|
') AS extollo_target_query',
|
||||||
`OFFSET ${start} LIMIT ${(end - start) + 1}`,
|
`OFFSET ${start} LIMIT ${(end - start) + 1}`, // FIXME - the +1 is only needed when start === end
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +89,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public renderSelect(builder: AbstractBuilder<any>): string {
|
public renderSelect(builder: AbstractBuilder<any>): string {
|
||||||
|
const rawSql = builder.appliedRawSql
|
||||||
|
if ( rawSql ) {
|
||||||
|
return rawSql
|
||||||
|
}
|
||||||
|
|
||||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||||
.join(' ') + item
|
.join(' ') + item
|
||||||
const queryLines = [
|
const queryLines = [
|
||||||
@@ -147,6 +156,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
|
|
||||||
// TODO support FROM, RETURNING
|
// TODO support FROM, RETURNING
|
||||||
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
||||||
|
const rawSql = builder.appliedRawSql
|
||||||
|
if ( rawSql ) {
|
||||||
|
return rawSql
|
||||||
|
}
|
||||||
|
|
||||||
const queryLines: string[] = []
|
const queryLines: string[] = []
|
||||||
|
|
||||||
// Add table source
|
// Add table source
|
||||||
@@ -171,6 +185,15 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public renderExistential(builder: AbstractBuilder<any>): string {
|
public renderExistential(builder: AbstractBuilder<any>): string {
|
||||||
|
const rawSql = builder.appliedRawSql
|
||||||
|
if ( rawSql ) {
|
||||||
|
return `
|
||||||
|
SELECT EXISTS(
|
||||||
|
${rawSql}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
const query = builder.clone()
|
const query = builder.clone()
|
||||||
.clearFields()
|
.clearFields()
|
||||||
.field(raw('TRUE'))
|
.field(raw('TRUE'))
|
||||||
@@ -181,6 +204,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
|
|
||||||
// FIXME: subquery support here and with select
|
// FIXME: subquery support here and with select
|
||||||
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
|
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
|
||||||
|
const rawSql = builder.appliedRawSql
|
||||||
|
if ( rawSql ) {
|
||||||
|
return rawSql
|
||||||
|
}
|
||||||
|
|
||||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||||
.join(' ') + item
|
.join(' ') + item
|
||||||
const queryLines: string[] = []
|
const queryLines: string[] = []
|
||||||
@@ -188,6 +216,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
if ( !Array.isArray(data) ) {
|
if ( !Array.isArray(data) ) {
|
||||||
data = [data]
|
data = [data]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( data.length < 1 ) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const columns = Object.keys(data[0])
|
const columns = Object.keys(data[0])
|
||||||
|
|
||||||
// Add table source
|
// Add table source
|
||||||
@@ -227,6 +260,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public renderDelete(builder: AbstractBuilder<any>): string {
|
public renderDelete(builder: AbstractBuilder<any>): string {
|
||||||
|
const rawSql = builder.appliedRawSql
|
||||||
|
if ( rawSql ) {
|
||||||
|
return rawSql
|
||||||
|
}
|
||||||
|
|
||||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
||||||
.join(' ') + item
|
.join(' ') + item
|
||||||
const queryLines: string[] = []
|
const queryLines: string[] = []
|
||||||
@@ -270,6 +308,11 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
if ( isConstraintGroup(constraint) ) {
|
if ( isConstraintGroup(constraint) ) {
|
||||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
|
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
|
||||||
} else if ( isConstraintItem(constraint) ) {
|
} else if ( isConstraintItem(constraint) ) {
|
||||||
|
if ( Array.isArray(constraint.operand) && !constraint.operand.length ) {
|
||||||
|
statements.push(`${indent}1 = 0 -- ${constraint.field} ${constraint.operator} empty set`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const field: string = constraint.field.split('.').map(x => `"${x}"`)
|
const field: string = constraint.field.split('.').map(x => `"${x}"`)
|
||||||
.join('.')
|
.join('.')
|
||||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
||||||
@@ -294,4 +337,247 @@ export class PostgreSQLDialect extends SQLDialect {
|
|||||||
|
|
||||||
return ['SET', ...sets].join('\n')
|
return ['SET', ...sets].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public renderCreateTable(builder: TableBuilder): string {
|
||||||
|
const cols = this.renderTableColumns(builder).map(x => ` ${x}`)
|
||||||
|
|
||||||
|
const builderConstraints = builder.getConstraints()
|
||||||
|
const constraints: string[] = []
|
||||||
|
for ( const constraintName in builderConstraints ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(builderConstraints, constraintName) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintBuilder = builderConstraints[constraintName]
|
||||||
|
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
|
||||||
|
if ( constraintDefinition ) {
|
||||||
|
constraints.push(` CONSTRAINT ${constraintDefinition}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
`CREATE TABLE ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name} (`,
|
||||||
|
[
|
||||||
|
...cols,
|
||||||
|
...constraints,
|
||||||
|
].join(',\n'),
|
||||||
|
`)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderTableColumns(builder: TableBuilder): string[] {
|
||||||
|
const defined = builder.getColumns()
|
||||||
|
const rendered: string[] = []
|
||||||
|
|
||||||
|
for ( const columnName in defined ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(defined, columnName) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnBuilder = defined[columnName]
|
||||||
|
rendered.push(this.renderColumnDefinition(columnBuilder))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a constraint schema-builder, render the constraint definition.
|
||||||
|
* @param builder
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected renderConstraintDefinition(builder: ConstraintBuilder): Maybe<string> {
|
||||||
|
const constraintType = builder.getType()
|
||||||
|
if ( constraintType === ConstraintType.Unique ) {
|
||||||
|
const fields = builder.getFields()
|
||||||
|
.map(x => `"${x}"`)
|
||||||
|
.join(',')
|
||||||
|
|
||||||
|
return `${builder.name} UNIQUE(${fields})`
|
||||||
|
} else if ( constraintType === ConstraintType.Check ) {
|
||||||
|
const expression = builder.getExpression()
|
||||||
|
if ( !expression ) {
|
||||||
|
throw new ErrorWithContext('Cannot create check constraint without expression.', {
|
||||||
|
constraintName: builder.name,
|
||||||
|
tableName: builder.parent.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${builder.name} CHECK(${expression})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a column-builder, render the SQL-definition as used in
|
||||||
|
* CREATE TABLE and ALTER TABLE statements.
|
||||||
|
* @fixme Type `serial` only exists on CREATE TABLE... queries
|
||||||
|
* @param builder
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected renderColumnDefinition(builder: ColumnBuilder): string {
|
||||||
|
const type = builder.getType()
|
||||||
|
if ( !type ) {
|
||||||
|
throw new ErrorWithContext(`Missing field type for column: ${builder.name}`, {
|
||||||
|
columnName: builder.name,
|
||||||
|
columnType: type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let render = `"${builder.name}" ${inverseFieldType(type)}`
|
||||||
|
|
||||||
|
if ( builder.getLength() ) {
|
||||||
|
render += `(${builder.getLength()})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue = builder.getDefaultValue()
|
||||||
|
if ( typeof defaultValue !== 'undefined' ) {
|
||||||
|
render += ` DEFAULT ${this.escape(defaultValue)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( builder.isPrimary() ) {
|
||||||
|
render += ` CONSTRAINT ${builder.name}_pk PRIMARY KEY`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( builder.isUnique() ) {
|
||||||
|
render += ` UNIQUE`
|
||||||
|
}
|
||||||
|
|
||||||
|
render += ` ${builder.isNullable() ? 'NULL' : 'NOT NULL'}`
|
||||||
|
return render
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderDropTable(builder: TableBuilder): string {
|
||||||
|
return `DROP TABLE ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderCreateIndex(builder: IndexBuilder): string {
|
||||||
|
const cols = builder.getFields().map(x => `"${x}"`)
|
||||||
|
const parts = [
|
||||||
|
`CREATE ${builder.isUnique() ? 'UNIQUE ' : ''}INDEX ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name}`,
|
||||||
|
` ON ${builder.parent.name}`,
|
||||||
|
` (${cols.join(',')})`,
|
||||||
|
]
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderAlterTable(builder: TableBuilder): string {
|
||||||
|
const alters: string[] = []
|
||||||
|
const columns = builder.getColumns()
|
||||||
|
|
||||||
|
for ( const columnName in columns ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(columns, columnName) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnBuilder = columns[columnName]
|
||||||
|
if ( !columnBuilder.isExisting() ) {
|
||||||
|
// The column doesn't exist on the table, but was added to the schema
|
||||||
|
alters.push(` ADD COLUMN ${this.renderColumnDefinition(columnBuilder)}`)
|
||||||
|
} else if ( columnBuilder.isDirty() && columnBuilder.originalFromSchema ) {
|
||||||
|
// The column exists in the table, but was modified in the schema
|
||||||
|
if ( columnBuilder.isDropping() || columnBuilder.isDroppingIfExists() ) {
|
||||||
|
alters.push(` DROP COLUMN "${columnBuilder.name}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the data type of the column
|
||||||
|
if ( columnBuilder.getType() !== columnBuilder.originalFromSchema.getType() ) {
|
||||||
|
const renderedType = `${columnBuilder.getType()}${columnBuilder.getLength() ? `(${columnBuilder.getLength()})` : ''}`
|
||||||
|
alters.push(` ALTER COLUMN "${columnBuilder.name}" TYPE ${renderedType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the default value of the column
|
||||||
|
if ( columnBuilder.getDefaultValue() !== columnBuilder.originalFromSchema.getDefaultValue() ) {
|
||||||
|
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET default ${this.escape(columnBuilder.getDefaultValue())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the nullable-status of the column
|
||||||
|
if ( columnBuilder.isNullable() !== columnBuilder.originalFromSchema.isNullable() ) {
|
||||||
|
if ( columnBuilder.isNullable() ) {
|
||||||
|
alters.push(` ALTER COLUMN "${columnBuilder.name}" DROP NOT NULL`)
|
||||||
|
} else {
|
||||||
|
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET NOT NULL`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the name of the column
|
||||||
|
if ( columnBuilder.getRename() ) {
|
||||||
|
alters.push(` RENAME COLUMN "${columnBuilder.name}" TO "${columnBuilder.getRename()}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = builder.getConstraints()
|
||||||
|
for ( const constraintName in constraints ) {
|
||||||
|
if ( !Object.prototype.hasOwnProperty.call(constraints, constraintName) ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintBuilder = constraints[constraintName]
|
||||||
|
|
||||||
|
// Drop the constraint if specified
|
||||||
|
if ( constraintBuilder.isDropping() ) {
|
||||||
|
alters.push(` DROP CONSTRAINT ${constraintBuilder.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the constraint with IF EXISTS if specified
|
||||||
|
if ( constraintBuilder.isDroppingIfExists() ) {
|
||||||
|
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, drop and recreate the constraint if it was modified
|
||||||
|
if ( constraintBuilder.isDirty() ) {
|
||||||
|
if ( constraintBuilder.isExisting() ) {
|
||||||
|
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
|
||||||
|
if ( constraintDefinition ) {
|
||||||
|
alters.push(` ADD CONSTRAINT ${constraintDefinition}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( builder.getRename() ) {
|
||||||
|
alters.push(` RENAME TO "${builder.getRename()}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ALTER TABLE ' + builder.name + '\n' + alters.join(',\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderDropIndex(builder: IndexBuilder): string {
|
||||||
|
return `DROP INDEX ${builder.isDroppingIfExists() ? 'IF EXISTS ' : ''}${builder.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderTransaction(queries: string[]): string {
|
||||||
|
const parts = [
|
||||||
|
'BEGIN',
|
||||||
|
...queries,
|
||||||
|
'COMMIT',
|
||||||
|
]
|
||||||
|
|
||||||
|
return parts.join(';\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderRenameIndex(builder: IndexBuilder): string {
|
||||||
|
return `ALTER INDEX ${builder.name} RENAME TO ${builder.getRename()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderRecreateIndex(builder: IndexBuilder): string {
|
||||||
|
return `${this.renderDropIndex(builder)};\n\n${this.renderCreateIndex(builder)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderDropColumn(builder: ColumnBuilder): string {
|
||||||
|
const parts = [
|
||||||
|
`ALTER TABLE ${builder.parent.name} ${builder.parent.isSkippedIfExisting() ? 'IF EXISTS ' : ''}`,
|
||||||
|
` DROP COLUMN ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Constraint} from '../types'
|
import {Constraint} from '../types'
|
||||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
|
import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A value which can be escaped to be interpolated into an SQL query.
|
* A value which can be escaped to be interpolated into an SQL query.
|
||||||
@@ -160,10 +161,141 @@ export abstract class SQLDialect extends AppClass {
|
|||||||
* This function should escape the values before they are included in the query string.
|
* This function should escape the values before they are included in the query string.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
|
* ```ts
|
||||||
* dialect.renderUpdateSet({field1: 'value', field2: 45})
|
* dialect.renderUpdateSet({field1: 'value', field2: 45})
|
||||||
* // => "SET field1 = 'value', field2 = 45"
|
* // => "SET field1 = 'value', field2 = 45"
|
||||||
|
* ```
|
||||||
*
|
*
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
public abstract renderUpdateSet(data: {[key: string]: EscapeValue}): string;
|
public abstract renderUpdateSet(data: {[key: string]: EscapeValue}): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a table schema-builder, render a `CREATE TABLE...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderCreateTable(builder: TableBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a table schema-builder, render an `ALTER TABLE...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderAlterTable(builder: TableBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a table schema-builder, render a `DROP TABLE...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderDropTable(builder: TableBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the table-column definitions for the table defined by
|
||||||
|
* the given schema-builder.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* dialect.renderTableColumns(builder)
|
||||||
|
* // => ['col1 varchar(100) NULL', 'col2 serial NOT NULL']
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderTableColumns(builder: TableBuilder): string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an index schema-builder, render a `CREATE INDEX...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderCreateIndex(builder: IndexBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a column schema-builder, render an `ALTER TABLE... DROP COLUMN...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderDropColumn(builder: ColumnBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an index schema-builder, render a `DROP INDEX...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderDropIndex(builder: IndexBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an index schema-builder, render an `ALTER INDEX... RENAME...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderRenameIndex(builder: IndexBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an index schema-builder, render either an `ALTER INDEX...` query,
|
||||||
|
* or a `DROP INDEX...; CREATE INDEX...` query.
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public abstract renderRecreateIndex(builder: IndexBuilder): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a series of fully-formed queries, render them as a single transaction.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const queries = [
|
||||||
|
* 'SELECT * FROM a',
|
||||||
|
* 'UPDATE b SET col = 123',
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* dialect.renderTransaction(queries)
|
||||||
|
* // => 'BEGIN; SELECT * FROM a; UPDATE b SET col = 123; COMMIT;'
|
||||||
|
* ```
|
||||||
|
* @param queries
|
||||||
|
*/
|
||||||
|
public abstract renderTransaction(queries: string[]): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a table schema-builder, render a series of queries as a transaction
|
||||||
|
* that apply the given schema to database.
|
||||||
|
* @todo handle constraints better - ConstraintBuilder
|
||||||
|
* @param builder
|
||||||
|
*/
|
||||||
|
public renderCommitSchemaTransaction(builder: TableBuilder): string {
|
||||||
|
if ( builder.isDropping() || builder.isDroppingIfExists() ) {
|
||||||
|
// If we're dropping the table, just return the DROP TABLE query
|
||||||
|
return this.renderTransaction([
|
||||||
|
this.renderDropTable(builder),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the queries to create/update/drop indexes
|
||||||
|
const indexes = Object.values(builder.getIndexes())
|
||||||
|
.filter(index => !index.isExisting() || index.isDirty())
|
||||||
|
.map(index => {
|
||||||
|
if ( index.isDropping() || index.isDroppingIfExists() ) {
|
||||||
|
return this.renderDropIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( index.isExisting() ) {
|
||||||
|
// The index was changed in the schema, but exists in the DB
|
||||||
|
return this.renderRecreateIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.renderCreateIndex(index)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Render the queries to rename indexes AFTER the above operations
|
||||||
|
const renamedIndexes = Object.values(builder.getIndexes())
|
||||||
|
.filter(idx => idx.getRename())
|
||||||
|
.map(x => this.renderRenameIndex(x))
|
||||||
|
|
||||||
|
let parts: string[] = []
|
||||||
|
|
||||||
|
// Render the CREATE/ALTER TABLE query
|
||||||
|
if ( !builder.isExisting() && builder.isDirty() ) {
|
||||||
|
parts.push(this.renderCreateTable(builder))
|
||||||
|
} else if ( builder.isExisting() && builder.isDirty() ) {
|
||||||
|
parts.push(this.renderAlterTable(builder))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the various schema queries as a single transaction
|
||||||
|
parts = parts.concat(...indexes)
|
||||||
|
parts = parts.concat(...renamedIndexes)
|
||||||
|
return this.renderTransaction(parts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/orm/directive/CreateMigrationDirective.ts
Normal file
50
src/orm/directive/CreateMigrationDirective.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {stringToPascal} from '../../util'
|
||||||
|
import {templateMigration} from '../template/migration'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that creates migration classes from template.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
|
export class CreateMigrationDirective extends Directive {
|
||||||
|
getDescription(): string {
|
||||||
|
return 'create a new migration'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['create-migration', 'make-migration']
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'{description} | Description of what the migration does',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Creates a new migration file in `src/app/migrations`.',
|
||||||
|
'To use, specify a string describing what the migration does. For example:',
|
||||||
|
'./ex create-migration "Add version column to sessions table"',
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const description = this.option('description')
|
||||||
|
const className = `${stringToPascal(description)}Migration`
|
||||||
|
const fileName = `${(new Date()).toISOString()}_${className}.migration.ts`
|
||||||
|
const path = this.app().path('..', 'src', 'app', 'migrations', fileName)
|
||||||
|
|
||||||
|
// Create the migrations directory, if it doesn't already exist
|
||||||
|
await path.concat('..').mkdir()
|
||||||
|
|
||||||
|
// Render the template
|
||||||
|
const rendered = await templateMigration.render(className, className, path)
|
||||||
|
await path.write(rendered)
|
||||||
|
|
||||||
|
this.success(`Created migration: ${className}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/orm/directive/MigrateDirective.ts
Normal file
119
src/orm/directive/MigrateDirective.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
|
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
|
||||||
|
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
|
||||||
|
import {EventSubscription} from '../../event/types'
|
||||||
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that applies migrations using the default Migrator.
|
||||||
|
* @fixme Support dry run mode
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
|
export class MigrateDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/** Event bus subscriptions. */
|
||||||
|
protected subscriptions: EventSubscription[] = []
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['migrate']
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'apply pending migrations'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--package -p {name} | apply migrations for a specific namespace',
|
||||||
|
'--identifier -i {name} | apply a specific migration, by identifier',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Migrations are single-run code patches used to track changes to things like database schemata.',
|
||||||
|
'',
|
||||||
|
'You can create migrations in your app using the ./ex command and they can be applied and rolled-back.',
|
||||||
|
'',
|
||||||
|
'./ex migrate:create "Add version column to sessions table"',
|
||||||
|
'',
|
||||||
|
'Modules and packages can also register their own migrations. These are run by default.',
|
||||||
|
'',
|
||||||
|
'To run the migrations for a specific package, and no others, use the --package option. Example:',
|
||||||
|
'',
|
||||||
|
'./ex migrate --package @extollo',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
await this.registerListeners()
|
||||||
|
|
||||||
|
const namespace = this.option('package')
|
||||||
|
const identifier = this.option('identifier')
|
||||||
|
|
||||||
|
let identifiers
|
||||||
|
if ( namespace ) {
|
||||||
|
identifiers = (this.injector.make<Migrations>(Migrations))
|
||||||
|
.all(namespace)
|
||||||
|
.map(id => `${namespace}:${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( identifier ) {
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = [identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = identifiers.filter(x => x === identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await (this.injector.make<Migrator>(Migrator)).migrate(identifiers)
|
||||||
|
} catch (e) {
|
||||||
|
if ( e instanceof NothingToMigrateError ) {
|
||||||
|
this.info(e.message)
|
||||||
|
} else {
|
||||||
|
error = e
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( error ) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register event bus listeners to print messages for the user.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async registerListeners(): Promise<void> {
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(ApplyingMigrationEvent, event => {
|
||||||
|
this.info(`Applying migration ${event.migration.identifier}...`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(AppliedMigrationEvent, event => {
|
||||||
|
this.success(`Applied migration: ${event.migration.identifier}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove event bus listeners before finish. */
|
||||||
|
protected async removeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
|
||||||
|
this.subscriptions = []
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/orm/directive/RollbackDirective.ts
Normal file
104
src/orm/directive/RollbackDirective.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../cli'
|
||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {Migrator} from '../migrations/Migrator'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent'
|
||||||
|
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
|
||||||
|
import {EventSubscription} from '../../event/types'
|
||||||
|
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
|
||||||
|
import {CLIDirective} from '../../cli/decorators'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI directive that undoes applied migrations using the default Migrator.
|
||||||
|
* @fixme Support dry run mode
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
@CLIDirective()
|
||||||
|
export class RollbackDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
/** Event bus subscriptions. */
|
||||||
|
protected subscriptions: EventSubscription[] = []
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['rollback']
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'roll-back applied migrations'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--identifier -i {name} | roll-back a specific migration, by identifier',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return [
|
||||||
|
'Use this command to undo one or more migrations that were applied.',
|
||||||
|
'',
|
||||||
|
'By default, the command will undo all of the migrations applied the last time the migrate command was run.',
|
||||||
|
'',
|
||||||
|
'To undo a specific migration, pass its identifier using the --identifier option.',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
await this.registerListeners()
|
||||||
|
|
||||||
|
const identifier = this.option('identifier')
|
||||||
|
|
||||||
|
let identifiers
|
||||||
|
if ( identifier ) {
|
||||||
|
identifiers = [identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await (this.injector.make<Migrator>(Migrator)).rollback(identifiers)
|
||||||
|
} catch (e) {
|
||||||
|
if ( e instanceof NothingToMigrateError ) {
|
||||||
|
this.info(e.message)
|
||||||
|
} else {
|
||||||
|
error = e
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( error ) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register event-bus listeners to print messages for the user.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async registerListeners(): Promise<void> {
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(RollingBackMigrationEvent, event => {
|
||||||
|
this.info(`Rolling-back migration ${event.migration.identifier}...`)
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.subscriptions.push(await this.bus.subscribe(RolledBackMigrationEvent, event => {
|
||||||
|
this.success(`Rolled-back migration: ${event.migration.identifier}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove event bus listeners before finish. */
|
||||||
|
protected async removeListeners(): Promise<void> {
|
||||||
|
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
|
||||||
|
this.subscriptions = []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,9 @@ export * from './model/Field'
|
|||||||
export * from './model/ModelBuilder'
|
export * from './model/ModelBuilder'
|
||||||
export * from './model/ModelBuilder'
|
export * from './model/ModelBuilder'
|
||||||
export * from './model/ModelResultIterable'
|
export * from './model/ModelResultIterable'
|
||||||
|
export * from './model/events'
|
||||||
export * from './model/Model'
|
export * from './model/Model'
|
||||||
|
|
||||||
export * from './services/Database'
|
|
||||||
export * from './services/Models'
|
|
||||||
|
|
||||||
export * from './support/SessionModel'
|
export * from './support/SessionModel'
|
||||||
export * from './support/ORMSession'
|
export * from './support/ORMSession'
|
||||||
export * from './support/CacheModel'
|
export * from './support/CacheModel'
|
||||||
@@ -27,3 +25,26 @@ export * from './support/ORMCache'
|
|||||||
|
|
||||||
export * from './DatabaseService'
|
export * from './DatabaseService'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
|
export * from './schema/TableBuilder'
|
||||||
|
export * from './schema/Schema'
|
||||||
|
export * from './schema/PostgresSchema'
|
||||||
|
|
||||||
|
export * from './services/Migrations'
|
||||||
|
export * from './migrations/Migrator'
|
||||||
|
export * from './migrations/NothingToMigrateError'
|
||||||
|
export * from './migrations/events/MigrationEvent'
|
||||||
|
export * from './migrations/events/ApplyingMigrationEvent'
|
||||||
|
export * from './migrations/events/AppliedMigrationEvent'
|
||||||
|
export * from './migrations/events/RollingBackMigrationEvent'
|
||||||
|
export * from './migrations/events/RolledBackMigrationEvent'
|
||||||
|
export * from './migrations/Migration'
|
||||||
|
export * from './migrations/MigratorFactory'
|
||||||
|
export * from './migrations/DatabaseMigrator'
|
||||||
|
|
||||||
|
export * from './services/Database'
|
||||||
|
export * from './services/Models'
|
||||||
|
|
||||||
|
export * from './directive/CreateMigrationDirective'
|
||||||
|
export * from './directive/MigrateDirective'
|
||||||
|
export * from './directive/RollbackDirective'
|
||||||
|
|||||||
179
src/orm/migrations/DatabaseMigrator.ts
Normal file
179
src/orm/migrations/DatabaseMigrator.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {Migrator} from './Migrator'
|
||||||
|
import {DatabaseService} from '../DatabaseService'
|
||||||
|
import {FieldType} from '../types'
|
||||||
|
import {Migration} from './Migration'
|
||||||
|
import {Builder} from '../builder/Builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrator implementation that tracks applied migrations in a database table.
|
||||||
|
* @todo allow configuring more of this
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseMigrator extends Migrator {
|
||||||
|
@Inject()
|
||||||
|
protected readonly db!: DatabaseService
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/** True if we've initialized the migrator. */
|
||||||
|
protected initialized = false
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await super.initialize()
|
||||||
|
|
||||||
|
if ( this.initialized ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = this.db.get().schema()
|
||||||
|
if ( !(await schema.hasTable('migrations')) ) {
|
||||||
|
const table = await schema.table('migrations')
|
||||||
|
|
||||||
|
table.primaryKey('id', FieldType.serial).required()
|
||||||
|
|
||||||
|
table.column('identifier')
|
||||||
|
.type(FieldType.varchar)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('applygroup')
|
||||||
|
.type(FieldType.integer)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
table.column('applydate')
|
||||||
|
.type(FieldType.timestamp)
|
||||||
|
.required()
|
||||||
|
|
||||||
|
await schema.commit(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(migration: Migration): Promise<boolean> {
|
||||||
|
return this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('id')
|
||||||
|
.from('migrations')
|
||||||
|
.where('identifier', '=', migration.identifier)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
async markApplied(migrations: Migration | Migration[], applyDate: Date = new Date()): Promise<void> {
|
||||||
|
if ( !Array.isArray(migrations) ) {
|
||||||
|
migrations = [migrations]
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyGroup = await this.getNextGroupIdentifier()
|
||||||
|
const rows = migrations.map(migration => {
|
||||||
|
return {
|
||||||
|
applygroup: applyGroup,
|
||||||
|
applydate: applyDate,
|
||||||
|
identifier: migration.identifier,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.table('migrations')
|
||||||
|
.insert(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async unmarkApplied(migrations: Migration | Migration[]): Promise<void> {
|
||||||
|
if ( !Array.isArray(migrations) ) {
|
||||||
|
migrations = [migrations]
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifiers = migrations.map(migration => migration.identifier)
|
||||||
|
|
||||||
|
await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.table('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastApplyGroup(): Promise<string[]> {
|
||||||
|
const applyGroup = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('applygroup')
|
||||||
|
.from('migrations')
|
||||||
|
.get()
|
||||||
|
.max<number>('applygroup')
|
||||||
|
|
||||||
|
return this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.where('applygroup', '=', applyGroup)
|
||||||
|
.get()
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.pluck<string>('identifier')
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to look up the next `applygroup` that should be used.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async getNextGroupIdentifier(): Promise<number> {
|
||||||
|
const current = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('applygroup')
|
||||||
|
.from('migrations')
|
||||||
|
.get()
|
||||||
|
.max<number>('applygroup')
|
||||||
|
|
||||||
|
return (current ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have been applied.
|
||||||
|
* @override to make this more efficient
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
const existing = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.get()
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
|
||||||
|
return identifiers.filter(id => !existing.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have not been applied.
|
||||||
|
* @override to make this more efficient
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
const existing = await this.builder()
|
||||||
|
.connection('default')
|
||||||
|
.select('identifier')
|
||||||
|
.from('migrations')
|
||||||
|
.whereIn('identifier', identifiers)
|
||||||
|
.get()
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
|
||||||
|
return existing.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a query builder instance.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected builder(): Builder {
|
||||||
|
return this.injector.make<Builder>(Builder)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/orm/migrations/Migration.ts
Normal file
39
src/orm/migrations/Migration.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {Awaitable} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base-class for one-time migrations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Migration {
|
||||||
|
/** Set by the Migrations unit on load. */
|
||||||
|
protected migrationIdentifier!: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the migration identifier.
|
||||||
|
* This is used internally when the Migrations service loads
|
||||||
|
* the migration files to determine the ID from the file-name.
|
||||||
|
* It shouldn't be used externally.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public setMigrationIdentifier(name: string): void {
|
||||||
|
this.migrationIdentifier = name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unique identifier of this migration.
|
||||||
|
*/
|
||||||
|
public get identifier(): string {
|
||||||
|
return this.migrationIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the migration.
|
||||||
|
*/
|
||||||
|
abstract up(): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the migration.
|
||||||
|
*/
|
||||||
|
abstract down(): Awaitable<void>
|
||||||
|
}
|
||||||
295
src/orm/migrations/Migrator.ts
Normal file
295
src/orm/migrations/Migrator.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import {Container, Inject, Injectable} from '../../di'
|
||||||
|
import {Awaitable, collect, ErrorWithContext} from '../../util'
|
||||||
|
import {Migration} from './Migration'
|
||||||
|
import {Migrations} from '../services/Migrations'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
|
||||||
|
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
|
||||||
|
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
|
||||||
|
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
|
||||||
|
import {NothingToMigrateError} from './NothingToMigrateError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages single-run patches/migrations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Migrator {
|
||||||
|
@Inject(Migrations, { debug: true })
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
|
@Inject('injector')
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should resolve true if the given migration has already been applied.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public abstract has(migration: Migration): Awaitable<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should mark the given migrations as being applied.
|
||||||
|
*
|
||||||
|
* If a date is specified, then that is the timestamp when the migrations
|
||||||
|
* were applied, otherwise, use `new Date()`.
|
||||||
|
*
|
||||||
|
* @param migrations
|
||||||
|
* @param date
|
||||||
|
*/
|
||||||
|
public abstract markApplied(migrations: Migration | Migration[], date?: Date): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should un-mark the given migrations as being applied.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public abstract unmarkApplied(migration: Migration | Migration[]): Awaitable<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the identifiers of the last group of migrations that were applied.
|
||||||
|
*/
|
||||||
|
public abstract getLastApplyGroup(): Awaitable<string[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do any initial setup required to get the migrator ready.
|
||||||
|
* This can be overridden by implementation classes to do any necessary setup.
|
||||||
|
*/
|
||||||
|
public initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply pending migrations.
|
||||||
|
*
|
||||||
|
* If identifiers are specified, only the pending migrations with those
|
||||||
|
* identifiers are applied. If none are specified, all pending migrations
|
||||||
|
* will be applied.
|
||||||
|
*
|
||||||
|
* @param identifiers
|
||||||
|
*/
|
||||||
|
public async migrate(identifiers?: string[]): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = this.getAllMigrationIdentifiers()
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = (await this.filterAppliedMigrations(identifiers)).sort()
|
||||||
|
if ( !identifiers.length ) {
|
||||||
|
throw new NothingToMigrateError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = collect(identifiers)
|
||||||
|
.map(id => {
|
||||||
|
const migration = this.migrations.get(id)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, {
|
||||||
|
identifier: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return migration
|
||||||
|
})
|
||||||
|
|
||||||
|
await migrations.promiseMap(migration => {
|
||||||
|
return this.apply(migration)
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.markApplied(migrations.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback applied migrations.
|
||||||
|
*
|
||||||
|
* If specified, only applied migrations with the given identifiers will
|
||||||
|
* be rolled back. If not specified, then the last "batch" of applied
|
||||||
|
* migrations will be rolled back.
|
||||||
|
*
|
||||||
|
* @param identifiers
|
||||||
|
*/
|
||||||
|
public async rollback(identifiers?: string[]): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
if ( !identifiers ) {
|
||||||
|
identifiers = await this.getLastApplyGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiers = (await this.filterPendingMigrations(identifiers)).sort()
|
||||||
|
if ( !identifiers.length ) {
|
||||||
|
throw new NothingToMigrateError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = collect(identifiers)
|
||||||
|
.map(id => {
|
||||||
|
const migration = this.migrations.get(id)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, {
|
||||||
|
identifier: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return migration
|
||||||
|
})
|
||||||
|
|
||||||
|
await migrations.promiseMap(migration => {
|
||||||
|
return this.undo(migration)
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.unmarkApplied(migrations.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a single migration.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public async apply(migration: Migration): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
await this.applying(migration)
|
||||||
|
|
||||||
|
await migration.up()
|
||||||
|
|
||||||
|
await this.applied(migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a single migration.
|
||||||
|
* @param migration
|
||||||
|
*/
|
||||||
|
public async undo(migration: Migration): Promise<void> {
|
||||||
|
await this.initialize()
|
||||||
|
|
||||||
|
await this.rollingBack(migration)
|
||||||
|
|
||||||
|
await migration.down()
|
||||||
|
|
||||||
|
await this.rolledBack(migration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered migrations, by their string-form identifiers.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getAllMigrationIdentifiers(): string[] {
|
||||||
|
return collect<string>(this.migrations.namespaces())
|
||||||
|
.map(nsp => {
|
||||||
|
return this.migrations.all(nsp)
|
||||||
|
.map(id => `${nsp}:${id}`)
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
// non-namespaced migrations
|
||||||
|
coll.push(this.migrations.all())
|
||||||
|
return coll
|
||||||
|
})
|
||||||
|
.reduce((current, item) => {
|
||||||
|
return current.concat(item)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have been applied.
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
return collect(identifiers)
|
||||||
|
.partialMap(identifier => {
|
||||||
|
const migration = this.migrations.get(identifier)
|
||||||
|
if ( migration ) {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
migration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.promiseMap(async group => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
has: await this.has(group.migration),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.filter(group => !group.has)
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of migration identifiers, filter out those that have not been applied.
|
||||||
|
* @param identifiers
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
|
||||||
|
return collect(identifiers)
|
||||||
|
.partialMap(identifier => {
|
||||||
|
const migration = this.migrations.get(identifier)
|
||||||
|
if ( migration ) {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
migration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.asyncPipe()
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.promiseMap(async group => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
has: await this.has(group.migration),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.tap(coll => {
|
||||||
|
return coll.filter(group => group.has)
|
||||||
|
.pluck<string>('identifier')
|
||||||
|
.all()
|
||||||
|
})
|
||||||
|
.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the ApplyingMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async applying(migration: Migration): Promise<void> {
|
||||||
|
const event = <ApplyingMigrationEvent> this.injector.make(ApplyingMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the AppliedMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async applied(migration: Migration): Promise<void> {
|
||||||
|
const event = <AppliedMigrationEvent> this.injector.make(AppliedMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the RollingBackMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async rollingBack(migration: Migration): Promise<void> {
|
||||||
|
const event = <RollingBackMigrationEvent> this.injector.make(RollingBackMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire the RolledBackMigrationEvent.
|
||||||
|
* @param migration
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async rolledBack(migration: Migration): Promise<void> {
|
||||||
|
const event = <RolledBackMigrationEvent> this.injector.make(RolledBackMigrationEvent, migration)
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/orm/migrations/MigratorFactory.ts
Normal file
82
src/orm/migrations/MigratorFactory.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
|
||||||
|
} from '../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {Migrator} from './Migrator'
|
||||||
|
import {DatabaseMigrator} from './DatabaseMigrator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dependency injection factory that matches the abstract Migrator class
|
||||||
|
* and produces an instance of the configured session driver implementation.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
@FactoryProducer()
|
||||||
|
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({})
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): Migrator {
|
||||||
|
return new (this.getMigratorClass())()
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === Migrator
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getMigratorClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getMigratorClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured migrator backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<Migrator>
|
||||||
|
*/
|
||||||
|
protected getMigratorClass(): Instantiable<Migrator> {
|
||||||
|
const MigratorClass = this.config.get('database.migrations.driver', DatabaseMigrator)
|
||||||
|
|
||||||
|
if ( !isInstantiable(MigratorClass) || !(MigratorClass.prototype instanceof Migrator) ) {
|
||||||
|
const e = new ErrorWithContext('Provided migration driver class does not extend from @extollo/lib.Migrator')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'database.migrations.driver',
|
||||||
|
class: MigratorClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MigratorClass
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/orm/migrations/NothingToMigrateError.ts
Normal file
14
src/orm/migrations/NothingToMigrateError.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {ErrorWithContext} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when the migrator is run, but no migrations need
|
||||||
|
* to be applied/rolled-back.
|
||||||
|
*/
|
||||||
|
export class NothingToMigrateError extends ErrorWithContext {
|
||||||
|
constructor(
|
||||||
|
message = 'There is nothing to migrate',
|
||||||
|
context?: {[key: string]: any},
|
||||||
|
) {
|
||||||
|
super(message, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/orm/migrations/events/AppliedMigrationEvent.ts
Normal file
8
src/orm/migrations/events/AppliedMigrationEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired after a migration is applied.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AppliedMigrationEvent extends MigrationEvent {}
|
||||||
8
src/orm/migrations/events/ApplyingMigrationEvent.ts
Normal file
8
src/orm/migrations/events/ApplyingMigrationEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired before a migration is applied.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ApplyingMigrationEvent extends MigrationEvent {}
|
||||||
49
src/orm/migrations/events/MigrationEvent.ts
Normal file
49
src/orm/migrations/events/MigrationEvent.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {Event} from '../../../event/Event'
|
||||||
|
import {Migration} from '../Migration'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Migrations} from '../../services/Migrations'
|
||||||
|
import {ErrorWithContext} from '../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic base-class for migration-related events.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class MigrationEvent extends Event {
|
||||||
|
@Inject()
|
||||||
|
protected readonly migrations!: Migrations
|
||||||
|
|
||||||
|
/** The migration relevant to this event. */
|
||||||
|
private internalMigration: Migration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the relevant migration.
|
||||||
|
*/
|
||||||
|
public get migration(): Migration {
|
||||||
|
return this.internalMigration
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
migration: Migration,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.internalMigration = migration
|
||||||
|
}
|
||||||
|
|
||||||
|
dehydrate(): {identifier: string} {
|
||||||
|
return {
|
||||||
|
identifier: this.migration.identifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(state: {identifier: string}): void {
|
||||||
|
const migration = this.migrations.get(state.identifier)
|
||||||
|
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find migration with identifier: ${state.identifier}`, {
|
||||||
|
identifier: state.identifier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.internalMigration = migration
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/orm/migrations/events/RolledBackMigrationEvent.ts
Normal file
8
src/orm/migrations/events/RolledBackMigrationEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired after a migration has been rolled-back.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RolledBackMigrationEvent extends MigrationEvent {}
|
||||||
8
src/orm/migrations/events/RollingBackMigrationEvent.ts
Normal file
8
src/orm/migrations/events/RollingBackMigrationEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {Injectable} from '../../../di'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired before a migration is rolled back.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RollingBackMigrationEvent extends MigrationEvent {}
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
import {ModelKey, QueryRow, QuerySource} from '../types'
|
import {ModelKey, QueryRow, QuerySource} from '../types'
|
||||||
import {Container, Inject} from '../../di'
|
import {Container, Inject, Instantiable, StaticClass} from '../../di'
|
||||||
import {DatabaseService} from '../DatabaseService'
|
import {DatabaseService} from '../DatabaseService'
|
||||||
import {ModelBuilder} from './ModelBuilder'
|
import {ModelBuilder} from './ModelBuilder'
|
||||||
import {getFieldsMeta, ModelField} from './Field'
|
import {getFieldsMeta, ModelField} from './Field'
|
||||||
import {deepCopy, BehaviorSubject, Pipe, Collection} from '../../util'
|
import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util'
|
||||||
import {EscapeValueObject} from '../dialect/SQLDialect'
|
import {EscapeValueObject} from '../dialect/SQLDialect'
|
||||||
import {AppClass} from '../../lifecycle/AppClass'
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
import {Connection} from '../connection/Connection'
|
import {Connection} from '../connection/Connection'
|
||||||
|
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types'
|
||||||
|
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
|
||||||
|
import {ModelSavingEvent} from './events/ModelSavingEvent'
|
||||||
|
import {ModelSavedEvent} from './events/ModelSavedEvent'
|
||||||
|
import {ModelUpdatingEvent} from './events/ModelUpdatingEvent'
|
||||||
|
import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
|
||||||
|
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
|
||||||
|
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
|
||||||
|
import {EventBus} from '../../event/EventBus'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base for classes that are mapped to tables in a database.
|
* Base for classes that are mapped to tables in a database.
|
||||||
*/
|
*/
|
||||||
export abstract class Model<T extends Model<T>> extends AppClass {
|
export abstract class Model<T extends Model<T>> extends AppClass implements Bus {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly logging!: Logging;
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: EventBus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the connection this model should run through.
|
* The name of the connection this model should run through.
|
||||||
@@ -78,49 +90,10 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
protected originalSourceRow?: QueryRow
|
protected originalSourceRow?: QueryRow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Behavior subject that fires after the model is populated.
|
* Collection of event subscribers, by their events.
|
||||||
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected retrieved$ = new BehaviorSubject<Model<T>>()
|
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right before the model is saved.
|
|
||||||
*/
|
|
||||||
protected saving$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right after the model is saved.
|
|
||||||
*/
|
|
||||||
protected saved$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right before the model is updated.
|
|
||||||
*/
|
|
||||||
protected updating$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right after the model is updated.
|
|
||||||
*/
|
|
||||||
protected updated$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right before the model is inserted.
|
|
||||||
*/
|
|
||||||
protected creating$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right after the model is inserted.
|
|
||||||
*/
|
|
||||||
protected created$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right before the model is deleted.
|
|
||||||
*/
|
|
||||||
protected deleting$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Behavior subject that fires right after the model is deleted.
|
|
||||||
*/
|
|
||||||
protected deleted$ = new BehaviorSubject<Model<T>>()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the table name for this model.
|
* Get the table name for this model.
|
||||||
@@ -193,9 +166,16 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
values?: {[key: string]: any},
|
values?: {[key: string]: any},
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
this.initialize()
|
||||||
this.boot(values)
|
this.boot(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the model is instantiated. Use for any setup of events, &c.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected initialize(): void {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the model's properties from the given values and do any other initial setup.
|
* Initialize the model's properties from the given values and do any other initial setup.
|
||||||
*
|
*
|
||||||
@@ -228,7 +208,7 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.retrieved$.next(this)
|
await this.dispatch(new ModelRetrievedEvent<T>(this as any))
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,11 +572,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
* @param withoutTimestamps
|
* @param withoutTimestamps
|
||||||
*/
|
*/
|
||||||
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
||||||
await this.saving$.next(this)
|
await this.dispatch(new ModelSavingEvent<T>(this as any))
|
||||||
const ctor = this.constructor as typeof Model
|
const ctor = this.constructor as typeof Model
|
||||||
|
|
||||||
if ( this.exists() && this.isDirty() ) {
|
if ( this.exists() && this.isDirty() ) {
|
||||||
await this.updating$.next(this)
|
await this.dispatch(new ModelUpdatingEvent<T>(this as any))
|
||||||
|
|
||||||
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
||||||
(this as any)[ctor.UPDATED_AT] = new Date()
|
(this as any)[ctor.UPDATED_AT] = new Date()
|
||||||
@@ -617,9 +597,9 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
await this.assumeFromSource(data)
|
await this.assumeFromSource(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updated$.next(this)
|
await this.dispatch(new ModelUpdatedEvent<T>(this as any))
|
||||||
} else if ( !this.exists() ) {
|
} else if ( !this.exists() ) {
|
||||||
await this.creating$.next(this)
|
await this.dispatch(new ModelCreatingEvent<T>(this as any))
|
||||||
|
|
||||||
if ( !withoutTimestamps ) {
|
if ( !withoutTimestamps ) {
|
||||||
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
||||||
@@ -632,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -647,13 +629,38 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
if ( data ) {
|
if ( data ) {
|
||||||
await this.assumeFromSource(result)
|
await this.assumeFromSource(result)
|
||||||
}
|
}
|
||||||
await this.created$.next(this)
|
|
||||||
|
await this.dispatch(new ModelCreatedEvent<T>(this as any))
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saved$.next(this)
|
await this.dispatch(new ModelSavedEvent<T>(this as any))
|
||||||
return this
|
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.
|
||||||
*
|
*
|
||||||
@@ -803,10 +810,12 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
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])
|
||||||
@@ -822,4 +831,44 @@ export abstract class Model<T extends Model<T>> extends AppClass {
|
|||||||
protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void {
|
protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void {
|
||||||
(this as any)[thisFieldName] = object[objectFieldName]
|
(this as any)[thisFieldName] = object[objectFieldName]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, Instantiable<EventT>>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
|
||||||
|
const entry: EventSubscriberEntry<EventT> = {
|
||||||
|
id: uuid4(),
|
||||||
|
event,
|
||||||
|
subscriber,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modelEventBusSubscribers.push(entry)
|
||||||
|
return this.buildSubscription(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe<EventT extends Dispatchable>(subscriber: EventSubscriber<EventT>): Awaitable<void> {
|
||||||
|
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(event: Dispatchable): Promise<void> {
|
||||||
|
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
||||||
|
await this.modelEventBusSubscribers.where('event', '=', eventClass)
|
||||||
|
.promiseMap(entry => entry.subscriber(event))
|
||||||
|
|
||||||
|
await this.bus.dispatch(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an EventSubscription object for the subscriber of the given ID.
|
||||||
|
* @param id
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected buildSubscription(id: string): EventSubscription {
|
||||||
|
let subscribed = true
|
||||||
|
return {
|
||||||
|
unsubscribe: (): Awaitable<void> => {
|
||||||
|
if ( subscribed ) {
|
||||||
|
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id)
|
||||||
|
subscribed = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/orm/model/events/ModelCreatedEvent.ts
Normal file
9
src/orm/model/events/ModelCreatedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {Model} from '../Model'
|
||||||
|
import {ModelEvent} from './ModelEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired right after a model is inserted.
|
||||||
|
*/
|
||||||
|
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||||
|
|
||||||
|
}
|
||||||
9
src/orm/model/events/ModelCreatingEvent.ts
Normal file
9
src/orm/model/events/ModelCreatingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {Model} from '../Model'
|
||||||
|
import {ModelEvent} from './ModelEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired right before a model is inserted.
|
||||||
|
*/
|
||||||
|
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||||
|
|
||||||
|
}
|
||||||
9
src/orm/model/events/ModelDeletedEvent.ts
Normal file
9
src/orm/model/events/ModelDeletedEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {Model} from '../Model'
|
||||||
|
import {ModelEvent} from './ModelEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired right after a model is deleted.
|
||||||
|
*/
|
||||||
|
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||||
|
|
||||||
|
}
|
||||||
9
src/orm/model/events/ModelDeletingEvent.ts
Normal file
9
src/orm/model/events/ModelDeletingEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {Model} from '../Model'
|
||||||
|
import {ModelEvent} from './ModelEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired right before a model is deleted.
|
||||||
|
*/
|
||||||
|
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
|
||||||
|
|
||||||
|
}
|
||||||
31
src/orm/model/events/ModelEvent.ts
Normal file
31
src/orm/model/events/ModelEvent.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {Model} from '../Model'
|
||||||
|
import {Event} from '../../../event/Event'
|
||||||
|
import {JSONState} from '../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for events that concern an instance of a model.
|
||||||
|
*/
|
||||||
|
export abstract class ModelEvent<T extends Model<T>> extends Event {
|
||||||
|
/**
|
||||||
|
* The instance of the model.
|
||||||
|
*/
|
||||||
|
public instance!: T
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
instance?: T,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
if ( instance ) {
|
||||||
|
this.instance = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement serialization here
|
||||||
|
dehydrate(): Promise<JSONState> {
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
rehydrate(/* state: JSONState */): void | Promise<void> {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user