Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
Garrett Mills | 395e8e4d1c | 2 years ago |
@ -1,87 +1,228 @@
|
|||||||
---
|
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: kubernetes
|
type: docker
|
||||||
name: docs
|
name: docs
|
||||||
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
pod-security.kubernetes.io/audit: privileged
|
|
||||||
|
|
||||||
services:
|
|
||||||
- name: docker daemon
|
|
||||||
image: docker:dind
|
|
||||||
privileged: true
|
|
||||||
environment:
|
|
||||||
DOCKER_TLS_CERTDIR: ""
|
|
||||||
when:
|
|
||||||
event: tag
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: typedoc build
|
# ============ BUILD STEPS ===============
|
||||||
image: node:18
|
- name: build documentation
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
commands:
|
commands:
|
||||||
- "node -v"
|
- pnpm i --silent
|
||||||
- "npm add --global pnpm"
|
- pnpm docs:build
|
||||||
- "pnpm --version"
|
- cd docs && tar czf ../extollo_api_documentation.tar.gz www
|
||||||
- pnpm i
|
|
||||||
- pnpm run docs:build
|
|
||||||
|
|
||||||
- name: container build
|
# =============== DEPLOY STEPS ===============
|
||||||
image: docker:latest
|
- name: copy artifacts to static host
|
||||||
privileged: true
|
image: appleboy/drone-scp
|
||||||
commands:
|
settings:
|
||||||
- "while ! docker stats --no-stream; do sleep 1; done"
|
host:
|
||||||
- docker image build docs -t $DOCKER_REGISTRY/extollo/docs:latest
|
from_secret: docs_deploy_host
|
||||||
- docker push $DOCKER_REGISTRY/extollo/docs:latest
|
username:
|
||||||
environment:
|
from_secret: docs_deploy_user
|
||||||
DOCKER_HOST: tcp://localhost:2375
|
key:
|
||||||
DOCKER_REGISTRY:
|
from_secret: docs_deploy_key
|
||||||
from_secret: DOCKER_REGISTRY
|
port: 22
|
||||||
|
source: extollo_api_documentation.tar.gz
|
||||||
|
target: /var/nfs/storage/static/sites/extollo
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: promote
|
||||||
status: success
|
target: docs
|
||||||
|
|
||||||
- name: k8s rollout
|
- name: deploy artifacts on static host
|
||||||
image: bitnami/kubectl
|
image: appleboy/drone-ssh
|
||||||
commands:
|
settings:
|
||||||
- cd docs/deploy && kubectl apply -f .
|
host:
|
||||||
- kubectl rollout restart -n extollo deployment/docs
|
from_secret: docs_deploy_host
|
||||||
|
username:
|
||||||
|
from_secret: docs_deploy_user
|
||||||
|
key:
|
||||||
|
from_secret: docs_deploy_key
|
||||||
|
port: 22
|
||||||
|
script:
|
||||||
|
- cd /var/nfs/storage/static/sites/extollo
|
||||||
|
- rm -rf docs
|
||||||
|
- tar xzf extollo_api_documentation.tar.gz
|
||||||
|
- rm -rf extollo_api_documentation.tar.gz
|
||||||
|
- mv www docs
|
||||||
|
when:
|
||||||
|
event: promote
|
||||||
|
target: docs
|
||||||
|
|
||||||
|
# =============== BUILD NOTIFICATIONS ===============
|
||||||
|
- name: send build success notifications
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: notify_webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
||||||
|
"message": "Build & deploy completed successfully.",
|
||||||
|
"priority": 4
|
||||||
|
}
|
||||||
when:
|
when:
|
||||||
event: tag
|
|
||||||
status: success
|
status: success
|
||||||
|
event:
|
||||||
|
- promote
|
||||||
|
|
||||||
|
- name: send build error notifications
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: notify_webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
||||||
|
"message": "Documentation build failed!",
|
||||||
|
"priority": 6
|
||||||
|
}
|
||||||
|
when:
|
||||||
|
status: failure
|
||||||
---
|
---
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: npm
|
|
||||||
|
|
||||||
|
kind: pipeline
|
||||||
|
name: default
|
||||||
|
type: docker
|
||||||
steps:
|
steps:
|
||||||
- name: node.js build
|
- name: post build in progress comment to PR
|
||||||
image: node:18
|
image: tsakidev/giteacomment:latest
|
||||||
|
settings:
|
||||||
|
gitea_token:
|
||||||
|
from_secret: gitea_token
|
||||||
|
gitea_base_url: https://code.garrettmills.dev
|
||||||
|
comment: "Build ${DRONE_BUILD_NUMBER} started."
|
||||||
|
when:
|
||||||
|
event: pull_request
|
||||||
|
|
||||||
|
- name: remove lockfile
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
|
- rm -rf pnpm-lock.yaml
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude: tag
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
commands:
|
commands:
|
||||||
- "npm add --global pnpm"
|
|
||||||
- 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
|
||||||
|
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||||
|
|
||||||
- name: gitea release
|
- name: create Gitea release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
settings:
|
settings:
|
||||||
api_key:
|
api_key:
|
||||||
from_secret: GITEA_TOKEN
|
from_secret: gitea_token
|
||||||
base_url: https://code.garrettmills.dev
|
base_url: https://code.garrettmills.dev
|
||||||
checksum: md5
|
checksum: md5
|
||||||
title: ${DRONE_TAG}
|
title: ${DRONE_TAG}
|
||||||
|
files: "artifacts/*"
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
status: success
|
||||||
|
|
||||||
|
- name: prepare NPM release
|
||||||
|
image: glmdev/node-pnpm:latest
|
||||||
|
commands:
|
||||||
|
- rm -rf artifacts
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
status: success
|
status: success
|
||||||
|
|
||||||
- name: npm release
|
- name: create NPM release
|
||||||
image: plugins/npm
|
image: plugins/npm
|
||||||
settings:
|
settings:
|
||||||
username: extollo_bot
|
username: extollo_bot
|
||||||
password:
|
password:
|
||||||
from_secret: NPM_PASSWORD
|
from_secret: npm_password
|
||||||
email: extollo@garrettmills.dev
|
email: extollo@garrettmills.dev
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
status: success
|
status: success
|
||||||
|
|
||||||
|
- name: send build success notifications
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: notify_webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||||
|
"message": "Build completed successfully.",
|
||||||
|
"priority": 4
|
||||||
|
}
|
||||||
|
when:
|
||||||
|
status: success
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- pull_request
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: send publish success notifications
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: notify_webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||||
|
"message": "Successfully published tag ${DRONE_TAG}.",
|
||||||
|
"priority": 4
|
||||||
|
}
|
||||||
|
when:
|
||||||
|
status: success
|
||||||
|
event: tag
|
||||||
|
|
||||||
|
- name: post build success comment to PR
|
||||||
|
image: tsakidev/giteacomment:latest
|
||||||
|
settings:
|
||||||
|
gitea_token:
|
||||||
|
from_secret: gitea_token
|
||||||
|
gitea_base_url: https://code.garrettmills.dev
|
||||||
|
comment: "Build ${DRONE_BUILD_NUMBER} completed successfully."
|
||||||
|
when:
|
||||||
|
status: success
|
||||||
|
event: pull_request
|
||||||
|
|
||||||
|
- name: send build error notifications
|
||||||
|
image: plugins/webhook
|
||||||
|
settings:
|
||||||
|
urls:
|
||||||
|
from_secret: notify_webhook_url
|
||||||
|
content_type: application/json
|
||||||
|
template: |
|
||||||
|
{
|
||||||
|
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
||||||
|
"message": "Build failed!",
|
||||||
|
"priority": 6
|
||||||
|
}
|
||||||
|
when:
|
||||||
|
status: failure
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
- name: post build error comment to PR
|
||||||
|
image: tsakidev/giteacomment:latest
|
||||||
|
settings:
|
||||||
|
gitea_token:
|
||||||
|
from_secret: gitea_token
|
||||||
|
gitea_base_url: https://code.garrettmills.dev
|
||||||
|
comment: "Build ${DRONE_BUILD_NUMBER} failed!"
|
||||||
|
when:
|
||||||
|
status: failure
|
||||||
|
event: pull_request
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
FROM joseluisq/static-web-server:2
|
|
||||||
|
|
||||||
COPY ./www /public
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: extollo
|
|
@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: docs
|
|
||||||
namespace: extollo
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: docs
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
name: docs
|
|
||||||
namespace: extollo
|
|
||||||
labels:
|
|
||||||
app: docs
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: docs-www
|
|
||||||
image: registry.millslan.net/extollo/docs
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: docs-service
|
|
||||||
namespace: extollo
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: docs
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: docs-service-lb
|
|
||||||
namespace: extollo
|
|
||||||
spec:
|
|
||||||
type: LoadBalancer
|
|
||||||
selector:
|
|
||||||
app: docs
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: cert-manager.io/v1
|
|
||||||
kind: Certificate
|
|
||||||
metadata:
|
|
||||||
name: docs-tls
|
|
||||||
namespace: extollo
|
|
||||||
spec:
|
|
||||||
secretName: docs-tls-secret
|
|
||||||
dnsNames:
|
|
||||||
- 'extollo.garrettmills.dev'
|
|
||||||
issuerRef:
|
|
||||||
name: letsencrypt-ca
|
|
||||||
kind: ClusterIssuer
|
|
@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: docs-ingress
|
|
||||||
namespace: extollo
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- extollo.garrettmills.dev
|
|
||||||
secretName: docs-tls-secret
|
|
||||||
ingressClassName: nginx
|
|
||||||
rules:
|
|
||||||
- host: extollo.garrettmills.dev
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- pathType: Prefix
|
|
||||||
path: '/'
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: docs-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
@ -0,0 +1 @@
|
|||||||
|
# About the Extollo Project
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,44 +0,0 @@
|
|||||||
import {SecurityContext} from './SecurityContext'
|
|
||||||
import {AuthenticatableRepository} from '../types'
|
|
||||||
import {Awaitable} from '../../util'
|
|
||||||
import {Inject} from '../../di'
|
|
||||||
import {Request} from '../../http/lifecycle/Request'
|
|
||||||
import {OAuth2Token, TokenRepository} from '../server/types'
|
|
||||||
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
|
||||||
|
|
||||||
export class TokenSecurityContext extends SecurityContext {
|
|
||||||
@Inject()
|
|
||||||
protected readonly request!: Request
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly tokens!: TokenRepository
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public readonly repository: AuthenticatableRepository,
|
|
||||||
) {
|
|
||||||
super(repository, 'token')
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
persist(): Awaitable<void> {}
|
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
|
||||||
if ( !this.request.hasInstance(OAuth2Token) ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
|
||||||
if ( !token.userId ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.repository.getByIdentifier(token.userId)
|
|
||||||
if ( user ) {
|
|
||||||
this.authenticatedUser = user
|
|
||||||
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.authenticatedUser = undefined
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import {BaseEvent, BaseSerializer, ObjectSerializer} from '../../support/bus'
|
|
||||||
import {Awaitable} from '../../util'
|
|
||||||
|
|
||||||
/** An event raised when a required auth check has failed. */
|
|
||||||
export class AuthCheckFailed extends BaseEvent {
|
|
||||||
eventName = '@extollo/lib:AuthCheckFailed'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serializes AuthCheckFailed events. */
|
|
||||||
@ObjectSerializer()
|
|
||||||
export class AuthCheckFailedSerializer extends BaseSerializer<AuthCheckFailed, { authCheckFailed: true }> {
|
|
||||||
protected decodeSerial(): Awaitable<AuthCheckFailed> {
|
|
||||||
return new AuthCheckFailed()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected encodeActual(): Awaitable<{ authCheckFailed: true }> {
|
|
||||||
return { authCheckFailed: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getName(): string {
|
|
||||||
return '@extollo/lib:AuthCheckFailedSerializer'
|
|
||||||
}
|
|
||||||
|
|
||||||
matchActual(some: AuthCheckFailed): boolean {
|
|
||||||
return some instanceof AuthCheckFailed
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
|
||||||
import {OAuth2Token} from '../server/types'
|
|
||||||
import {HTTPError} from '../../http/HTTPError'
|
|
||||||
import {HTTPStatus, Pipeline} from '../../util'
|
|
||||||
import {Request} from '../../http/lifecycle/Request'
|
|
||||||
import {Constructable, Container} from '../../di'
|
|
||||||
|
|
||||||
export class ScopeRequiredMiddleware extends Middleware {
|
|
||||||
constructor(
|
|
||||||
protected readonly request: Request,
|
|
||||||
protected readonly scope: string,
|
|
||||||
) {
|
|
||||||
super(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(): ResponseObject {
|
|
||||||
if ( !this.request.hasInstance(OAuth2Token) ) {
|
|
||||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Must specify an OAuth2 token.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
|
||||||
if ( typeof token.scope !== 'undefined' && token.scope !== this.scope ) {
|
|
||||||
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Insufficient token permissions (requires: ' + this.scope + ')')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const scope = (name: string): Constructable<ScopeRequiredMiddleware> => {
|
|
||||||
return new Pipeline<Container, ScopeRequiredMiddleware>(
|
|
||||||
container => container.make(ScopeRequiredMiddleware, container, name),
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
|
||||||
import {Inject, Injectable} from '../../di'
|
|
||||||
import {Config} from '../../service/Config'
|
|
||||||
import {Logging} from '../../service/Logging'
|
|
||||||
import {AuthenticatableRepository} from '../types'
|
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
|
||||||
import {SecurityContext} from '../context/SecurityContext'
|
|
||||||
import {TokenSecurityContext} from '../context/TokenSecurityContext'
|
|
||||||
import {OAuth2Token, oauth2TokenString, TokenRepository} from '../server/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Injects a TokenSecurityContext into the request and attempts to
|
|
||||||
* resume the user's authentication.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class TokenAuthMiddleware extends Middleware {
|
|
||||||
@Inject()
|
|
||||||
protected readonly config!: Config
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly tokens!: TokenRepository
|
|
||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
|
||||||
this.logging.debug('Applying token auth middleware.')
|
|
||||||
let tokenString = this.request.getHeader('Authorization')
|
|
||||||
if ( Array.isArray(tokenString) ) {
|
|
||||||
tokenString = tokenString[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( tokenString ) {
|
|
||||||
const token = await this.tokens.decode(oauth2TokenString(tokenString))
|
|
||||||
if ( token ) {
|
|
||||||
this.request.registerSingletonInstance(OAuth2Token, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
|
||||||
const context = <TokenSecurityContext> this.make(TokenSecurityContext, repo)
|
|
||||||
this.request.registerSingletonInstance(SecurityContext, context)
|
|
||||||
await context.resume()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../di'
|
|
||||||
import {AuthenticatableRepository} from '../types'
|
|
||||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
|
||||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A dependency injection factory that matches the abstract ClientRepository class
|
|
||||||
* and produces an instance of the configured repository driver implementation.
|
|
||||||
*/
|
|
||||||
@FactoryProducer()
|
|
||||||
export class AuthenticatableRepositoryFactory extends ConfiguredSingletonFactory<AuthenticatableRepository> {
|
|
||||||
protected getConfigKey(): string {
|
|
||||||
return 'auth.storage'
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<AuthenticatableRepository> {
|
|
||||||
return ORMUserRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
|
||||||
return AuthenticatableRepository
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +1,74 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../../di'
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||||
|
} from '../../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../../util'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
import {ClientRepository} from '../types'
|
import {ClientRepository} from '../types'
|
||||||
import {ConfigClientRepository} from './ConfigClientRepository'
|
import {ConfigClientRepository} from './ConfigClientRepository'
|
||||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dependency injection factory that matches the abstract ClientRepository class
|
* A dependency injection factory that matches the abstract ClientRepository class
|
||||||
* and produces an instance of the configured repository driver implementation.
|
* and produces an instance of the configured repository driver implementation.
|
||||||
*/
|
*/
|
||||||
@FactoryProducer()
|
@FactoryProducer()
|
||||||
export class ClientRepositoryFactory extends ConfiguredSingletonFactory<ClientRepository> {
|
export class ClientRepositoryFactory extends AbstractFactory<ClientRepository> {
|
||||||
protected getConfigKey(): string {
|
protected get config(): Config {
|
||||||
return 'oauth2.repository.client'
|
return Container.getContainer().make<Config>(Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<ClientRepository> {
|
produce(): ClientRepository {
|
||||||
return ConfigClientRepository
|
return new (this.getClientRepositoryClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
match(something: unknown): boolean {
|
||||||
return ClientRepository
|
return something === ClientRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getClientRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getClientRepositoryClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured client repository backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<ClientRepository>
|
||||||
|
*/
|
||||||
|
protected getClientRepositoryClass(): Instantiable<ClientRepository> {
|
||||||
|
const ClientRepositoryClass = this.config.get('oauth2.repository.client', ConfigClientRepository)
|
||||||
|
|
||||||
|
if ( !isInstantiable(ClientRepositoryClass) || !(ClientRepositoryClass.prototype instanceof ClientRepository) ) {
|
||||||
|
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ClientRepository')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'oauth2.repository.client',
|
||||||
|
class: ClientRepositoryClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClientRepositoryClass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,74 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../../di'
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||||
|
} from '../../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../../util'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
import {RedemptionCodeRepository} from '../types'
|
import {RedemptionCodeRepository} from '../types'
|
||||||
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
|
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
|
||||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
|
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
|
||||||
* and produces an instance of the configured repository driver implementation.
|
* and produces an instance of the configured repository driver implementation.
|
||||||
*/
|
*/
|
||||||
@FactoryProducer()
|
@FactoryProducer()
|
||||||
export class RedemptionCodeRepositoryFactory extends ConfiguredSingletonFactory<RedemptionCodeRepository> {
|
export class RedemptionCodeRepositoryFactory extends AbstractFactory<RedemptionCodeRepository> {
|
||||||
protected getConfigKey(): string {
|
protected get config(): Config {
|
||||||
return 'oauth2.repository.client'
|
return Container.getContainer().make<Config>(Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<RedemptionCodeRepository> {
|
produce(): RedemptionCodeRepository {
|
||||||
return CacheRedemptionCodeRepository
|
return new (this.getRedemptionCodeRepositoryClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
match(something: unknown): boolean {
|
||||||
return RedemptionCodeRepository
|
return something === RedemptionCodeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getRedemptionCodeRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getRedemptionCodeRepositoryClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured client repository backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<RedemptionCodeRepository>
|
||||||
|
*/
|
||||||
|
protected getRedemptionCodeRepositoryClass(): Instantiable<RedemptionCodeRepository> {
|
||||||
|
const RedemptionCodeRepositoryClass = this.config.get('oauth2.repository.client', CacheRedemptionCodeRepository)
|
||||||
|
|
||||||
|
if ( !isInstantiable(RedemptionCodeRepositoryClass) || !(RedemptionCodeRepositoryClass.prototype instanceof RedemptionCodeRepository) ) {
|
||||||
|
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.RedemptionCodeRepository')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'oauth2.repository.client',
|
||||||
|
class: RedemptionCodeRepositoryClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedemptionCodeRepositoryClass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,74 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../../di'
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||||
|
} from '../../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../../util'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
import {ScopeRepository} from '../types'
|
import {ScopeRepository} from '../types'
|
||||||
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
||||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dependency injection factory that matches the abstract ScopeRepository class
|
* A dependency injection factory that matches the abstract ScopeRepository class
|
||||||
* and produces an instance of the configured repository driver implementation.
|
* and produces an instance of the configured repository driver implementation.
|
||||||
*/
|
*/
|
||||||
@FactoryProducer()
|
@FactoryProducer()
|
||||||
export class ScopeRepositoryFactory extends ConfiguredSingletonFactory<ScopeRepository> {
|
export class ScopeRepositoryFactory extends AbstractFactory<ScopeRepository> {
|
||||||
protected getConfigKey(): string {
|
protected get config(): Config {
|
||||||
return 'oauth2.repository.scope'
|
return Container.getContainer().make<Config>(Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<ScopeRepository> {
|
produce(): ScopeRepository {
|
||||||
return ConfigScopeRepository
|
return new (this.getScopeRepositoryClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
match(something: unknown): boolean {
|
||||||
return ScopeRepository
|
return something === ScopeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getScopeRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getScopeRepositoryClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured scope repository backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<ScopeRepository>
|
||||||
|
*/
|
||||||
|
protected getScopeRepositoryClass(): Instantiable<ScopeRepository> {
|
||||||
|
const ScopeRepositoryClass = this.config.get('oauth2.repository.scope', ConfigScopeRepository)
|
||||||
|
|
||||||
|
if ( !isInstantiable(ScopeRepositoryClass) || !(ScopeRepositoryClass.prototype instanceof ScopeRepository) ) {
|
||||||
|
const e = new ErrorWithContext('Provided client repository class does not extend from @extollo/lib.ScopeRepository')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'oauth2.repository.client',
|
||||||
|
class: ScopeRepositoryClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScopeRepositoryClass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,74 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../../di'
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, FactoryProducer,
|
||||||
|
} from '../../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../../util'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
import {TokenRepository} from '../types'
|
import {TokenRepository} from '../types'
|
||||||
import {ORMTokenRepository} from './ORMTokenRepository'
|
import {ORMTokenRepository} from './ORMTokenRepository'
|
||||||
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dependency injection factory that matches the abstract TokenRepository class
|
* A dependency injection factory that matches the abstract TokenRepository class
|
||||||
* and produces an instance of the configured repository driver implementation.
|
* and produces an instance of the configured repository driver implementation.
|
||||||
*/
|
*/
|
||||||
@FactoryProducer()
|
@FactoryProducer()
|
||||||
export class TokenRepositoryFactory extends ConfiguredSingletonFactory<TokenRepository> {
|
export class TokenRepositoryFactory extends AbstractFactory<TokenRepository> {
|
||||||
protected getConfigKey(): string {
|
protected get config(): Config {
|
||||||
return 'oauth2.repository.token'
|
return Container.getContainer().make<Config>(Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<TokenRepository> {
|
produce(): TokenRepository {
|
||||||
return ORMTokenRepository
|
return new (this.getTokenRepositoryClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
match(something: unknown): boolean {
|
||||||
return TokenRepository
|
return something === TokenRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getTokenRepositoryClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getTokenRepositoryClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the instantiable class of the configured token repository backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<TokenRepository>
|
||||||
|
*/
|
||||||
|
protected getTokenRepositoryClass(): Instantiable<TokenRepository> {
|
||||||
|
const TokenRepositoryClass = this.config.get('oauth2.repository.token', ORMTokenRepository)
|
||||||
|
|
||||||
|
if ( !isInstantiable(TokenRepositoryClass) || !(TokenRepositoryClass.prototype instanceof TokenRepository) ) {
|
||||||
|
const e = new ErrorWithContext('Provided token repository class does not extend from @extollo/lib.TokenRepository')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'oauth2.repository.client',
|
||||||
|
class: TokenRepositoryClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TokenRepositoryClass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import {Container} from '../di'
|
|
||||||
import {RequestLocalStorage} from '../http/RequestLocalStorage'
|
|
||||||
import {Session} from '../http/session/Session'
|
|
||||||
import {Logging} from '../service/Logging'
|
|
||||||
import {SecurityContext} from './context/SecurityContext'
|
|
||||||
import {Bus} from '../support/bus'
|
|
||||||
import {AuthCheckFailed} from './event/AuthCheckFailed'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the security context for the current request's web socket is still valid.
|
|
||||||
* If not, raise an `AuthCheckFailed` event. This is meant to be used as a subscriber
|
|
||||||
* to `WebSocketHealthCheckEvent` on the request.
|
|
||||||
*
|
|
||||||
* @see AuthCheckFailed
|
|
||||||
*/
|
|
||||||
export async function webSocketAuthCheck(): Promise<void> {
|
|
||||||
const request = Container.getContainer()
|
|
||||||
.make<RequestLocalStorage>(RequestLocalStorage)
|
|
||||||
.get()
|
|
||||||
|
|
||||||
const logging = request.make<Logging>(Logging)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to re-load the session in case we're using the SessionSecurityContext
|
|
||||||
await request.make<Session>(Session).load()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
logging.error(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
const security = request.make<SecurityContext>(SecurityContext)
|
|
||||||
await security.resume()
|
|
||||||
|
|
||||||
if ( !security.hasUser() ) {
|
|
||||||
await request.make<Bus>(Bus).push(new AuthCheckFailed())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import {Instantiable, PropertyDependency} from '../types'
|
|
||||||
import {Collection, logIfDebugging} from '../../util'
|
|
||||||
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
|
|
||||||
|
|
||||||
export function getPropertyInjectionMetadata(token: Instantiable<any>): Collection<PropertyDependency> {
|
|
||||||
const loadedMeta = ((token as any)[propertyInjectionMetadata] || new Collection()) as Collection<PropertyDependency>
|
|
||||||
logIfDebugging('extollo.di.injection', 'getPropertyInjectionMetadata() target:', token, 'loaded:', loadedMeta)
|
|
||||||
return loadedMeta
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
export const propertyInjectionMetadata = Symbol('@extollo/lib:propertyInjectionMetadata')
|
|
@ -1,12 +1,11 @@
|
|||||||
import {DependencyKey} from '../types'
|
import {DependencyKey} from '../types'
|
||||||
import {ErrorWithContext} from '../../util/error/ErrorWithContext'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
||||||
* @extends Error
|
* @extends Error
|
||||||
*/
|
*/
|
||||||
export class InvalidDependencyKeyError extends ErrorWithContext {
|
export class InvalidDependencyKeyError extends Error {
|
||||||
constructor(key: DependencyKey, context: {[key: string]: any} = {}) {
|
constructor(key: DependencyKey) {
|
||||||
super(`No such dependency is registered with this container: ${key}`, context)
|
super(`No such dependency is registered with this container: ${key}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
import {AbstractFactory} from './AbstractFactory'
|
|
||||||
import {Inject, Injectable} from '../decorator/injection'
|
|
||||||
import {Logging} from '../../service/Logging'
|
|
||||||
import {Config} from '../../service/Config'
|
|
||||||
import {
|
|
||||||
DEPENDENCY_KEYS_METADATA_KEY,
|
|
||||||
DependencyRequirement,
|
|
||||||
Instantiable,
|
|
||||||
isInstantiable,
|
|
||||||
PropertyDependency,
|
|
||||||
} from '../types'
|
|
||||||
import {Collection, ErrorWithContext, Maybe} from '../../util'
|
|
||||||
import 'reflect-metadata'
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export abstract class ConfiguredSingletonFactory<T> extends AbstractFactory<T> {
|
|
||||||
protected static loggedDefaultImplementationWarningOnce = false
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly config!: Config
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({})
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract getConfigKey(): string
|
|
||||||
|
|
||||||
protected abstract getDefaultImplementation(): Instantiable<T>
|
|
||||||
|
|
||||||
protected abstract getAbstractImplementation(): any
|
|
||||||
|
|
||||||
protected getDefaultImplementationWarning(): Maybe<string> {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
produce(dependencies: any[], parameters: any[]): T {
|
|
||||||
return new (this.getImplementation())(...dependencies, ...parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
match(something: unknown): boolean {
|
|
||||||
return something === this.getAbstractImplementation()
|
|
||||||
}
|
|
||||||
|
|
||||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
|
||||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getImplementation())
|
|
||||||
if ( meta ) {
|
|
||||||
return meta
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Collection<DependencyRequirement>()
|
|
||||||
}
|
|
||||||
|
|
||||||
getInjectedProperties(): Collection<PropertyDependency> {
|
|
||||||
return this.getInjectedPropertiesForPrototypeChain(this.getImplementation())
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getImplementation(): Instantiable<T> {
|
|
||||||
const ctor = this.constructor as typeof ConfiguredSingletonFactory
|
|
||||||
const ImplementationClass = this.config.get(this.getConfigKey(), this.getDefaultImplementation())
|
|
||||||
if ( ImplementationClass === this.getDefaultImplementation() ) {
|
|
||||||
const warning = this.getDefaultImplementationWarning()
|
|
||||||
if ( warning && !ctor.loggedDefaultImplementationWarningOnce ) {
|
|
||||||
this.logging.warn(warning)
|
|
||||||
ctor.loggedDefaultImplementationWarningOnce = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isInstantiable(ImplementationClass)
|
|
||||||
|| !(ImplementationClass.prototype instanceof this.getAbstractImplementation())
|
|
||||||
) {
|
|
||||||
throw new ErrorWithContext('Configured service clas does not properly extend from implementation base class.', {
|
|
||||||
configKey: this.getConfigKey(),
|
|
||||||
class: `${ImplementationClass}`,
|
|
||||||
mustExtendBase: `${this.getAbstractImplementation()}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return ImplementationClass as Instantiable<T>
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
import {HTTPKernel} from '../HTTPKernel'
|
|
||||||
import {Request} from '../../lifecycle/Request'
|
|
||||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
|
||||||
import {withErrorContext} from '../../../util'
|
|
||||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
|
||||||
import {ExecuteResolvedRoutePreflightHTTPModule} from './ExecuteResolvedRoutePreflightHTTPModule'
|
|
||||||
import {WebSocketBus} from '../../../support/bus'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP kernel module that runs the web socket handler for the socket connection's route.
|
|
||||||
*/
|
|
||||||
export class ExecuteResolvedWebSocketHandlerHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
|
|
||||||
public static register(kernel: HTTPKernel): void {
|
|
||||||
kernel.register(this).after(ExecuteResolvedRoutePreflightHTTPModule)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async apply(request: Request): Promise<Request> {
|
|
||||||
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
|
|
||||||
const params = route.resolvedParameters
|
|
||||||
if ( !params ) {
|
|
||||||
throw new Error('Attempted to call route handler without resolved parameters.')
|
|
||||||
}
|
|
||||||
|
|
||||||
await withErrorContext(async () => {
|
|
||||||
const ws = request.make<WebSocketBus>(WebSocketBus)
|
|
||||||
await route.handler
|
|
||||||
.tap(handler => handler(ws, ...params))
|
|
||||||
.apply(request)
|
|
||||||
}, {
|
|
||||||
route,
|
|
||||||
})
|
|
||||||
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,96 +0,0 @@
|
|||||||
import {HTTPKernelModule} from '../HTTPKernelModule'
|
|
||||||
import {HTTPKernel} from '../HTTPKernel'
|
|
||||||
import {Request} from '../../lifecycle/Request'
|
|
||||||
import {Inject, Injectable} from '../../../di'
|
|
||||||
import {Config} from '../../../service/Config'
|
|
||||||
import {setInterval} from 'timers'
|
|
||||||
import {Logging} from '../../../service/Logging'
|
|
||||||
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
|
|
||||||
import Timeout = NodeJS.Timeout;
|
|
||||||
import {Bus} from '../../../support/bus'
|
|
||||||
import * as WebSockets from 'ws'
|
|
||||||
import {Maybe} from '../../../util'
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MonitorWebSocketConnectionHTTPModule extends HTTPKernelModule {
|
|
||||||
@Inject()
|
|
||||||
protected readonly config!: Config
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
public static register(kernel: HTTPKernel): void {
|
|
||||||
kernel.register(this).core()
|
|
||||||
}
|
|
||||||
|
|
||||||
async apply(request: Request): Promise<Request> {
|
|
||||||
const ws = request.make<WebSockets.WebSocket>(WebSockets.WebSocket)
|
|
||||||
|
|
||||||
// Time to wait between pings
|
|
||||||
const pollIntervalMs = this.config.safe('server.socket.pollIntervalMs')
|
|
||||||
.or(30000)
|
|
||||||
.integer()
|
|
||||||
|
|
||||||
// Time to wait for a response
|
|
||||||
const pollResponseTimeoutMs = this.config.safe('server.socket.pollResponseTimeoutMs')
|
|
||||||
.or(3000)
|
|
||||||
.integer()
|
|
||||||
|
|
||||||
// Max # of failures before the connection is closed
|
|
||||||
const maxFailedPolls = this.config.safe('server.socket.maxFailedPolls')
|
|
||||||
.or(5)
|
|
||||||
.integer()
|
|
||||||
|
|
||||||
let failedPolls = 0
|
|
||||||
let interval: Maybe<Timeout> = undefined
|
|
||||||
|
|
||||||
await new Promise<void>(res => {
|
|
||||||
let gotResponse = false
|
|
||||||
|
|
||||||
// Listen for pong responses
|
|
||||||
ws.on('pong', () => {
|
|
||||||
this.logging.verbose('Got pong response from socket.')
|
|
||||||
gotResponse = true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Listen for close event
|
|
||||||
ws.on('close', () => {
|
|
||||||
this.logging.debug('Got close event from socket.')
|
|
||||||
res()
|
|
||||||
})
|
|
||||||
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
// Every interval, send a ping request and set a timeout for the response
|
|
||||||
this.logging.verbose('Sending ping request to socket...')
|
|
||||||
gotResponse = false
|
|
||||||
ws.ping()
|
|
||||||
|
|
||||||
await new Promise<void>(res2 => setTimeout(res2, pollResponseTimeoutMs))
|
|
||||||
|
|
||||||
// If no pong response is received before the timeout occurs, tick the # of failed response
|
|
||||||
if ( !gotResponse ) {
|
|
||||||
this.logging.verbose('Socket failed to respond in time.')
|
|
||||||
failedPolls += 1
|
|
||||||
} else {
|
|
||||||
// Otherwise, reset the failure counter
|
|
||||||
failedPolls = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the failed responses exceeds the threshold, kill the connection
|
|
||||||
if ( failedPolls > maxFailedPolls ) {
|
|
||||||
this.logging.debug('Socket exceeded maximum # of failed pings. Killing.')
|
|
||||||
res()
|
|
||||||
}
|
|
||||||
}, pollIntervalMs)
|
|
||||||
})
|
|
||||||
|
|
||||||
if ( interval ) {
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell the server to close the socket connection
|
|
||||||
const bus = request.make<Bus>(Bus)
|
|
||||||
await bus.push(new WebSocketCloseEvent())
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import {Injectable, Inject} from '../../../di'
|
|
||||||
import {HTTPKernelModule} from '../HTTPKernelModule'
|
|
||||||
import {HTTPKernel} from '../HTTPKernel'
|
|
||||||
import {Request} from '../../lifecycle/Request'
|
|
||||||
import {Routing} from '../../../service/Routing'
|
|
||||||
import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
|
||||||
import {Logging} from '../../../service/Logging'
|
|
||||||
import {apiEvent, error} from '../../response/api'
|
|
||||||
import {Bus, WebSocketBus} from '../../../support/bus'
|
|
||||||
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP kernel middleware that tries to find a registered route matching the request's
|
|
||||||
* path and creates an ActivatedRoute instance from it, limited to websocket handling
|
|
||||||
* routes.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MountWebSocketRouteHTTPModule extends HTTPKernelModule {
|
|
||||||
public readonly executeWithBlockingWriteback = true
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly routing!: Routing
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
public static register(kernel: HTTPKernel): void {
|
|
||||||
kernel.register(this).before()
|
|
||||||
}
|
|
||||||
|
|
||||||
public async apply(request: Request): Promise<Request> {
|
|
||||||
const route = this.routing.match('ws', request.path)
|
|
||||||
if ( route ) {
|
|
||||||
this.logging.verbose(`Mounting activated WebSocket route: ${request.path} -> ${route}`)
|
|
||||||
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
|
|
||||||
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
|
|
||||||
} else {
|
|
||||||
this.logging.debug(`No matching WebSocket route found for: ${request.method} -> ${request.path}`)
|
|
||||||
|
|
||||||
// Send an error response on the socket to the client
|
|
||||||
const ws = request.make<WebSocketBus>(WebSocketBus)
|
|
||||||
await ws.push(apiEvent(error('Endpoint is not a configured socket listener.')))
|
|
||||||
|
|
||||||
// Then, terminate the request & socket connections
|
|
||||||
await request.make<Bus>(Bus).push(new WebSocketCloseEvent())
|
|
||||||
request.response.blockingWriteback(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import {Event} from '../../support/bus'
|
|
||||||
import {uuid4} from '../../util'
|
|
||||||
|
|
||||||
/** Event used to tell the server to close the websocket connection. */
|
|
||||||
export class WebSocketCloseEvent implements Event {
|
|
||||||
eventName = '@extollo/lib:WebSocketCloseEvent'
|
|
||||||
|
|
||||||
eventUuid = uuid4()
|
|
||||||
|
|
||||||
shouldBroadcast = false
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import {Event} from '../../support/bus'
|
|
||||||
import {uuid4} from '../../util'
|
|
||||||
|
|
||||||
/** Event used to tell the server to close the websocket connection. */
|
|
||||||
export class WebSocketHealthCheckEvent implements Event {
|
|
||||||
eventName = '@extollo/lib:WebSocketHealthCheckEvent'
|
|
||||||
|
|
||||||
eventUuid = uuid4()
|
|
||||||
|
|
||||||
shouldBroadcast = false
|
|
||||||
}
|
|
@ -1,105 +0,0 @@
|
|||||||
import {StateEvent, WebSocketBus} from '../../support/bus'
|
|
||||||
import {constructable, Constructable, Instantiable, TypedDependencyKey} from '../../di'
|
|
||||||
import {Awaitable, Collection, JSONState} from '../../util'
|
|
||||||
import {Request} from '../lifecycle/Request'
|
|
||||||
|
|
||||||
export type SocketEventHandler<TState extends JSONState> = {
|
|
||||||
eventClass: Instantiable<StateEvent<TState>>,
|
|
||||||
handler: Constructable<(state: TState) => Awaitable<void>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper class for building websocket APIs using first-class route syntax.
|
|
||||||
* This is returned by the `Route.socket(...)` method, so you'll probably want
|
|
||||||
* to use that.
|
|
||||||
*
|
|
||||||
* @see Route#socket
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* Route.socket('/ws/endpoint')
|
|
||||||
* .connected(MyCtrl, (ctrl: MyCtrl) => ctrl.connect)
|
|
||||||
* .event(MyEvent, MyCtrl, (ctrl: MyCtrl) => ctrl.myHandler)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class SocketRouteBuilder {
|
|
||||||
public static get(): SocketRouteBuilder {
|
|
||||||
return new SocketRouteBuilder()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handlers that should be registered with any new socket connections. */
|
|
||||||
protected handlers: Collection<SocketEventHandler<any>> = new Collection()
|
|
||||||
|
|
||||||
/** Callback to execute when a new connection is opened. */
|
|
||||||
protected connectionCallback?: Constructable<(ws: WebSocketBus) => Awaitable<void>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback to execute each time a new socket is opened.
|
|
||||||
* This can be used to perform basic setup/validation/authentication tasks.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* Route.socket('/ws/endpoint')
|
|
||||||
* .connected(MyCtrl, (ctrl: MyCtrl) => ctrl.connect)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* @param selector
|
|
||||||
*/
|
|
||||||
connected<TKey>(
|
|
||||||
key: TypedDependencyKey<TKey>,
|
|
||||||
selector: (x: TKey) => (ws: WebSocketBus) => Awaitable<void>,
|
|
||||||
): this {
|
|
||||||
this.connectionCallback = constructable<TKey>(key)
|
|
||||||
.tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((ws: WebSocketBus) => Awaitable<void>))
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a `StateEvent` listener on the socket.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* Route.socket('/ws/endpoint')
|
|
||||||
* .event(MyEvent, MyCtrl, (ctrl: MyCtrl) => ctrl.myHandler)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @see StateEvent
|
|
||||||
* @param eventClass
|
|
||||||
* @param key
|
|
||||||
* @param selector
|
|
||||||
*/
|
|
||||||
event<TState extends JSONState, TKey>(
|
|
||||||
eventClass: Instantiable<StateEvent<TState>>,
|
|
||||||
key: TypedDependencyKey<TKey>,
|
|
||||||
selector: (x: TKey) => (state: TState) => Awaitable<void>,
|
|
||||||
): this {
|
|
||||||
const handler = constructable<TKey>(key)
|
|
||||||
.tap(inst => Function.prototype.bind.call(selector(inst), inst as any) as ((state: TState) => Awaitable<void>))
|
|
||||||
|
|
||||||
this.handlers.push({
|
|
||||||
eventClass,
|
|
||||||
handler,
|
|
||||||
})
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches event listeners & initial callback to the given socket. This is used
|
|
||||||
* by as the handler for new connections.
|
|
||||||
*
|
|
||||||
* @see Route#socket
|
|
||||||
* @param request
|
|
||||||
* @param ws
|
|
||||||
*/
|
|
||||||
async build(request: Request, ws: WebSocketBus): Promise<void> {
|
|
||||||
await this.handlers.promiseMap(handler => {
|
|
||||||
ws.subscribe(handler.eventClass, (event: StateEvent<JSONState>) => {
|
|
||||||
return handler.handler.apply(request)(event.getState())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if ( this.connectionCallback ) {
|
|
||||||
await this.connectionCallback.apply(request)(ws)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from './Session'
|
|
||||||
import {Inject, Injectable} from '../../di'
|
|
||||||
import {Cache, Maybe} from '../../util'
|
|
||||||
import {Config} from '../../service/Config'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Session implementation that uses the configured Cache driver for persistence.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CacheSession extends Session {
|
|
||||||
@Inject()
|
|
||||||
protected readonly cache!: Cache
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly config!: Config
|
|
||||||
|
|
||||||
protected key?: string
|
|
||||||
|
|
||||||
protected data?: SessionData
|
|
||||||
|
|
||||||
protected dirty = false
|
|
||||||
|
|
||||||
forget(key: string): void {
|
|
||||||
if ( !this.data ) {
|
|
||||||
throw new SessionNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
delete this.data[key]
|
|
||||||
this.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: string, fallback?: unknown): any {
|
|
||||||
if ( !this.data ) {
|
|
||||||
throw new SessionNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.data[key] ?? fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
getData(): SessionData {
|
|
||||||
if ( !this.data ) {
|
|
||||||
throw new SessionNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {...this.data}
|
|
||||||
}
|
|
||||||
|
|
||||||
getKey(): string {
|
|
||||||
if ( !this.key ) {
|
|
||||||
throw new NoSessionKeyError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.key
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(): Promise<void> {
|
|
||||||
const json = await this.cache.fetch(this.formatKey())
|
|
||||||
if ( json ) {
|
|
||||||
this.data = JSON.parse(json)
|
|
||||||
} else {
|
|
||||||
this.data = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async persist(): Promise<void> {
|
|
||||||
if ( !this.dirty ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = JSON.stringify(this.data)
|
|
||||||
await this.cache.put(this.formatKey(), json, this.getExpiration())
|
|
||||||
this.dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private getExpiration(): Maybe<Date> {
|
|
||||||
// Get the session expiration. By default, this is 4 hours.
|
|
||||||
const durationMins = this.config.safe('server.session.durationMins')
|
|
||||||
.or(4 * 60)
|
|
||||||
.integer()
|
|
||||||
|
|
||||||
if ( durationMins !== 0 ) {
|
|
||||||
const date = new Date()
|
|
||||||
date.setMinutes(date.getMinutes() + durationMins)
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatKey(): string {
|
|
||||||
if ( !this.key ) {
|
|
||||||
throw new NoSessionKeyError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = this.config.safe('app.name')
|
|
||||||
.or('Extollo')
|
|
||||||
.string()
|
|
||||||
|
|
||||||
return `${prefix}_session_${this.key}`
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: string, value: unknown): void {
|
|
||||||
if ( !this.data ) {
|
|
||||||
throw new SessionNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.data[key] = value
|
|
||||||
this.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(data: SessionData): void {
|
|
||||||
this.data = {...data}
|
|
||||||
this.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
setKey(key: string): void {
|
|
||||||
this.key = key
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +1,87 @@
|
|||||||
import {Instantiable} from '../../di'
|
import {
|
||||||
import {Maybe} from '../../util'
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable,
|
||||||
|
} from '../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../util'
|
||||||
import {MemorySession} from './MemorySession'
|
import {MemorySession} from './MemorySession'
|
||||||
import {Session} from './Session'
|
import {Session} from './Session'
|
||||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
|
||||||
export class SessionFactory extends ConfiguredSingletonFactory<Session> {
|
/**
|
||||||
protected getConfigKey(): string {
|
* A dependency injection factory that matches the abstract Session class
|
||||||
return 'server.session.driver'
|
* and produces an instance of the configured session driver implementation.
|
||||||
|
*/
|
||||||
|
export class SessionFactory extends AbstractFactory<Session> {
|
||||||
|
protected readonly logging: Logging
|
||||||
|
|
||||||
|
protected readonly config: Config
|
||||||
|
|
||||||
|
/** True if we have printed the memory session warning at least once. */
|
||||||
|
private static loggedMemorySessionWarningOnce = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({})
|
||||||
|
this.logging = Container.getContainer().make<Logging>(Logging)
|
||||||
|
this.config = Container.getContainer().make<Config>(Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<Session> {
|
produce(): Session {
|
||||||
return MemorySession
|
return new (this.getSessionClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
match(something: unknown): boolean {
|
||||||
return Session
|
return something === Session
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementationWarning(): Maybe<string> {
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
return 'You are using the default memory-based session driver. It is recommended you configure a persistent session driver instead.'
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getSessionClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getSessionClass()
|
||||||
|
|
||||||
|
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 session backend.
|
||||||
|
* @protected
|
||||||
|
* @return Instantiable<Session>
|
||||||
|
*/
|
||||||
|
protected getSessionClass(): Instantiable<Session> {
|
||||||
|
const SessionClass = this.config.get('server.session.driver', MemorySession)
|
||||||
|
if ( SessionClass === MemorySession && !SessionFactory.loggedMemorySessionWarningOnce ) {
|
||||||
|
this.logging.warn(`You are using the default memory-based session driver. It is recommended you configure a persistent session driver instead.`)
|
||||||
|
SessionFactory.loggedMemorySessionWarningOnce = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !isInstantiable(SessionClass) || !(SessionClass.prototype instanceof Session) ) {
|
||||||
|
const e = new ErrorWithContext('Provided session class does not extend from @extollo/lib.Session')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'server.session.driver',
|
||||||
|
class: SessionClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SessionClass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,46 +0,0 @@
|
|||||||
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
|
|
||||||
import {Inject} from '../di'
|
|
||||||
|
|
||||||
export default class CreateOAuth2TokensTableMigration extends Migration {
|
|
||||||
@Inject()
|
|
||||||
protected readonly db!: DatabaseService
|
|
||||||
|
|
||||||
async up(): Promise<void> {
|
|
||||||
const db = this.db.get()
|
|
||||||
const table = await db.schema().table('oauth2_tokens')
|
|
||||||
|
|
||||||
table.primaryKey('oauth2_token_id').required()
|
|
||||||
|
|
||||||
table.column('user_id')
|
|
||||||
.type(FieldType.varchar)
|
|
||||||
.nullable()
|
|
||||||
|
|
||||||
table.column('client_id')
|
|
||||||
.type(FieldType.varchar)
|
|
||||||
.required()
|
|
||||||
|
|
||||||
table.column('issued')
|
|
||||||
.type(FieldType.timestamp)
|
|
||||||
.default(db.dialect().currentTimestamp())
|
|
||||||
.required()
|
|
||||||
|
|
||||||
table.column('expires')
|
|
||||||
.type(FieldType.timestamp)
|
|
||||||
.required()
|
|
||||||
|
|
||||||
table.column('scope')
|
|
||||||
.type(FieldType.varchar)
|
|
||||||
.nullable()
|
|
||||||
|
|
||||||
await db.schema().commit(table)
|
|
||||||
}
|
|
||||||
|
|
||||||
async down(): Promise<void> {
|
|
||||||
const schema: Schema = this.db.get().schema()
|
|
||||||
const table = await schema.table('oauth2_tokens')
|
|
||||||
|
|
||||||
table.dropIfExists()
|
|
||||||
|
|
||||||
await schema.commit(table)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,117 +0,0 @@
|
|||||||
import {Connection, ConnectionNotReadyError} from './Connection'
|
|
||||||
import {Logging} from '../../service/Logging'
|
|
||||||
import {Inject} from '../../di'
|
|
||||||
import {open, Database} from 'sqlite'
|
|
||||||
import {FieldType, QueryResult, QueryRow} from '../types'
|
|
||||||
import {Schema} from '../schema/Schema'
|
|
||||||
import {Awaitable, collect, Collection, hasOwnProperty, UniversalPath} from '../../util'
|
|
||||||
import {SQLDialect} from '../dialect/SQLDialect'
|
|
||||||
import {SQLiteDialect} from '../dialect/SQLiteDialect'
|
|
||||||
import {SQLiteSchema} from '../schema/SQLiteSchema'
|
|
||||||
import * as sqlite3 from 'sqlite3'
|
|
||||||
import {ModelField} from '../model/Field'
|
|
||||||
|
|
||||||
export interface SQLiteConnectionConfig {
|
|
||||||
filename: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SQLiteConnection extends Connection {
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
protected client?: Database
|
|
||||||
|
|
||||||
public dialect(): SQLDialect {
|
|
||||||
return this.make(SQLiteDialect)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init(): Promise<void> {
|
|
||||||
if ( this.config?.filename instanceof UniversalPath ) {
|
|
||||||
this.config.filename = this.config.filename.toLocal
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logging.debug(`Opening SQLite connection ${this.name} (${this.config?.filename})...`)
|
|
||||||
|
|
||||||
this.client = await open({
|
|
||||||
...this.config,
|
|
||||||
driver: sqlite3.Database,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
|
||||||
this.logging.debug(`Closing SQLite connection ${this.name}...`)
|
|
||||||
if ( this.client ) {
|
|
||||||
await this.client.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async query(query: string): Promise<QueryResult> {
|
|
||||||
if ( !this.client ) {
|
|
||||||
throw new ConnectionNotReadyError(this.name, {
|
|
||||||
config: this.config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logging.verbose(`Executing query in connection ${this.name}: \n${query.split('\n').map(x => ' ' + x)
|
|
||||||
.join('\n')}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.client.all(query) // FIXME: this probably won't work for non-select statements?
|
|
||||||
await this.queryExecuted(query)
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: collect(result),
|
|
||||||
rowCount: result.length,
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if ( e instanceof Error ) {
|
|
||||||
throw this.app().errorWrapContext(e, {
|
|
||||||
query,
|
|
||||||
connection: this.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
|
|
||||||
if ( !this.client ) {
|
|
||||||
throw new ConnectionNotReadyError(this.name, {
|
|
||||||
config: this.config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixme: sqlite doesn't support tx's properly in node atm
|
|
||||||
await this.client.run('BEGIN')
|
|
||||||
try {
|
|
||||||
const result = await closure()
|
|
||||||
await this.client.run('COMMIT')
|
|
||||||
return result
|
|
||||||
} catch (e) {
|
|
||||||
await this.client.run('ROLLBACK')
|
|
||||||
|
|
||||||
if ( e instanceof Error ) {
|
|
||||||
throw this.app().errorWrapContext(e, {
|
|
||||||
connection: this.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public normalizeRow(row: QueryRow, fields: Collection<ModelField>): QueryRow {
|
|
||||||
fields.where('type', '=', FieldType.json)
|
|
||||||
.pluck('databaseKey')
|
|
||||||
.filter(key => hasOwnProperty(row, key))
|
|
||||||
.filter(key => typeof row[key] === 'string')
|
|
||||||
.each(key => row[key] = JSON.parse(row[key]))
|
|
||||||
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|
||||||
public schema(): Schema {
|
|
||||||
return new SQLiteSchema(this)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,617 +0,0 @@
|
|||||||
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
|
|
||||||
import {Collection, ErrorWithContext, Maybe} from '../../util'
|
|
||||||
import {AbstractBuilder} from '../builder/AbstractBuilder'
|
|
||||||
import {
|
|
||||||
Constraint,
|
|
||||||
FieldType,
|
|
||||||
inverseFieldType,
|
|
||||||
isConstraintGroup,
|
|
||||||
isConstraintItem,
|
|
||||||
QuerySource,
|
|
||||||
SpecifiedField,
|
|
||||||
} from '../types'
|
|
||||||
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
|
|
||||||
|
|
||||||
export class SQLiteDialect extends SQLDialect {
|
|
||||||
public escape(value: EscapeValue): QuerySafeValue {
|
|
||||||
if ( value instanceof QuerySafeValue ) {
|
|
||||||
return value
|
|
||||||
} else if ( Array.isArray(value) || value instanceof Collection ) {
|
|
||||||
return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
|
|
||||||
} else if ( String(value).toLowerCase() === 'true' || value === true ) {
|
|
||||||
return new QuerySafeValue(value, 'TRUE')
|
|
||||||
} else if ( String(value).toLowerCase() === 'false' || value === false ) {
|
|
||||||
return new QuerySafeValue(value, 'FALSE')
|
|
||||||
} else if ( typeof value === 'number' ) {
|
|
||||||
return new QuerySafeValue(value, `${value}`)
|
|
||||||
} else if ( value instanceof Date ) {
|
|
||||||
const pad = (val: number) => val < 10 ? `0${val}` : `${val}`
|
|
||||||
const [y, m, d, h, i, s] = [
|
|
||||||
`${value.getFullYear()}`,
|
|
||||||
`${pad(value.getMonth() + 1)}`,
|
|
||||||
`${pad(value.getDate())}`,
|
|
||||||
`${pad(value.getHours())}`,
|
|
||||||
`${pad(value.getMinutes())}`,
|
|
||||||
`${pad(value.getSeconds())}`,
|
|
||||||
]
|
|
||||||
|
|
||||||
return new QuerySafeValue(value, `'${y}-${m}-${d} ${h}:${i}:${s}'`)
|
|
||||||
} else if ( value === null || typeof value === 'undefined' ) {
|
|
||||||
return new QuerySafeValue(value, 'NULL')
|
|
||||||
} else if ( !isNaN(Number(value)) ) {
|
|
||||||
return new QuerySafeValue(value, String(Number(value)))
|
|
||||||
} else {
|
|
||||||
const escaped = value.replace(/'/g, '\'\'')
|
|
||||||
return new QuerySafeValue(value, `'${escaped}'`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderQuerySource(source: QuerySource): string {
|
|
||||||
if ( source instanceof QuerySafeValue ) {
|
|
||||||
return String(source)
|
|
||||||
} else if ( typeof source === 'string' ) {
|
|
||||||
return source.replace(/"/g, '""')
|
|
||||||
.split('.')
|
|
||||||
.map(x => '"' + x + '"')
|
|
||||||
.join('.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${this.renderQuerySource(source.table)} AS "${source.alias.replace(/"/g, '""')}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderCount(query: string): string {
|
|
||||||
return [
|
|
||||||
'SELECT COUNT(*) AS "extollo_render_count"',
|
|
||||||
'FROM (',
|
|
||||||
...query.split('\n').map(x => ` ${x}`),
|
|
||||||
') AS extollo_target_query',
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderRangedSelect(query: string, start: number, end: number): string {
|
|
||||||
return [
|
|
||||||
'SELECT *',
|
|
||||||
'FROM (',
|
|
||||||
...query.split('\n').map(x => ` ${x}`),
|
|
||||||
') AS extollo_target_query',
|
|
||||||
`LIMIT ${start === end ? ((end - start) + 1) : (end - start)} OFFSET ${start}`,
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render the fields from the builder class to PostgreSQL syntax. */
|
|
||||||
protected renderFields(builder: AbstractBuilder<any>): string[] {
|
|
||||||
return builder.appliedFields.map((field: SpecifiedField) => {
|
|
||||||
let columnString: string
|
|
||||||
if ( typeof field === 'string' ) {
|
|
||||||
columnString = field.split('.').map(x => `"${x}"`)
|
|
||||||
.join('.')
|
|
||||||
} else if ( field instanceof QuerySafeValue ) {
|
|
||||||
columnString = field.toString()
|
|
||||||
} else if ( typeof field.field === 'string' ) {
|
|
||||||
columnString = field.field.split('.').map(x => `"${x}"`)
|
|
||||||
.join('.')
|
|
||||||
} else {
|
|
||||||
columnString = field.field.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
let aliasString = ''
|
|
||||||
if ( typeof field !== 'string' && !(field instanceof QuerySafeValue) ) {
|
|
||||||
aliasString = ` AS "${field.alias}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${columnString}${aliasString}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderSelect(builder: AbstractBuilder<any>): string {
|
|
||||||
const rawSql = builder.appliedRawSql
|
|
||||||
if ( rawSql ) {
|
|
||||||
return rawSql
|
|
||||||
}
|
|
||||||
|
|
||||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
|
||||||
.join(' ') + item
|
|
||||||
const queryLines = [
|
|
||||||
`SELECT${builder.appliedDistinction ? ' DISTINCT' : ''}`,
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add fields
|
|
||||||
// FIXME error if no fields
|
|
||||||
const fields = this.renderFields(builder).map(x => indent(x))
|
|
||||||
.join(',\n')
|
|
||||||
|
|
||||||
queryLines.push(fields)
|
|
||||||
|
|
||||||
// Add table source
|
|
||||||
// FIXME error if no source
|
|
||||||
const source = builder.querySource
|
|
||||||
if ( source ) {
|
|
||||||
queryLines.push('FROM ' + this.renderQuerySource(source))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add constraints
|
|
||||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
|
||||||
if ( wheres.trim() ) {
|
|
||||||
queryLines.push('WHERE')
|
|
||||||
queryLines.push(wheres)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add group by
|
|
||||||
if ( builder.appliedGroupings?.length ) {
|
|
||||||
const grouping = builder.appliedGroupings.map(group => {
|
|
||||||
return indent(group.split('.').map(x => `"${x}"`)
|
|
||||||
.join('.'))
|
|
||||||
}).join(',\n')
|
|
||||||
|
|
||||||
queryLines.push('GROUP BY')
|
|
||||||
queryLines.push(grouping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add order by
|
|
||||||
if ( builder.appliedOrder?.length ) {
|
|
||||||
const ordering = builder.appliedOrder.map(x => indent(`${x.field.split('.').map(y => '"' + y + '"')
|
|
||||||
.join('.')} ${x.direction}`)).join(',\n')
|
|
||||||
queryLines.push('ORDER BY')
|
|
||||||
queryLines.push(ordering)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add limit/offset
|
|
||||||
const pagination = builder.appliedPagination
|
|
||||||
if ( pagination.take ) {
|
|
||||||
queryLines.push(`LIMIT ${pagination.take}${pagination.skip ? ' OFFSET ' + pagination.skip : ''}`)
|
|
||||||
} else if ( pagination.skip ) {
|
|
||||||
queryLines.push(`LIMIT -1 OFFSET ${pagination.skip}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryLines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderBatchUpdate(): string {
|
|
||||||
throw new ErrorWithContext('SQLite dialect does not support batch updates.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO support FROM, RETURNING
|
|
||||||
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
|
||||||
const rawSql = builder.appliedRawSql
|
|
||||||
if ( rawSql ) {
|
|
||||||
return rawSql
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryLines: string[] = []
|
|
||||||
|
|
||||||
// Add table source
|
|
||||||
const source = builder.querySource
|
|
||||||
if ( source ) {
|
|
||||||
queryLines.push('UPDATE ' + this.renderQuerySource(source))
|
|
||||||
}
|
|
||||||
|
|
||||||
queryLines.push(this.renderUpdateSet(data))
|
|
||||||
|
|
||||||
// Add constraints
|
|
||||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
|
||||||
if ( wheres.trim() ) {
|
|
||||||
queryLines.push('WHERE')
|
|
||||||
queryLines.push(wheres)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = this.renderFields(builder).map(x => ` ${x}`)
|
|
||||||
.join(',\n')
|
|
||||||
|
|
||||||
if ( fields ) {
|
|
||||||
queryLines.push('RETURNING')
|
|
||||||
queryLines.push(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryLines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderExistential(builder: AbstractBuilder<any>): string {
|
|
||||||
const rawSql = builder.appliedRawSql
|
|
||||||
if ( rawSql ) {
|
|
||||||
return `
|
|
||||||
SELECT EXISTS(
|
|
||||||
${rawSql}
|
|
||||||
)
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = builder.clone()
|
|
||||||
.clearFields()
|
|
||||||
.field(raw('TRUE'))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return this.renderSelect(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: subquery support here and with select
|
|
||||||
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('')
|
|
||||||
.join(' ') + item
|
|
||||||
const queryLines: string[] = []
|
|
||||||
|
|
||||||
if ( !Array.isArray(data) ) {
|
|
||||||
data = [data]
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( data.length < 1 ) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = Object.keys(data[0])
|
|
||||||
|
|
||||||
// Add table source
|
|
||||||
const source = builder.querySource
|
|
||||||
if ( source ) {
|
|
||||||
queryLines.push('INSERT INTO ' + this.renderQuerySource(source)
|
|
||||||
+ (columns.length ? ` (${columns.map(x => `"${x}"`).join(', ')})` : ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( Array.isArray(data) && !data.length ) {
|
|
||||||
queryLines.push('DEFAULT VALUES')
|
|
||||||
} else {
|
|
||||||
queryLines.push('VALUES')
|
|
||||||
|
|
||||||
const valueString = data.map(row => {
|
|
||||||
const values = columns.map(x => this.escape(row[x]))
|
|
||||||
return indent(`(${values.join(', ')})`)
|
|
||||||
})
|
|
||||||
.join(',\n')
|
|
||||||
|
|
||||||
queryLines.push(valueString)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add return fields
|
|
||||||
if ( builder.appliedFields?.length ) {
|
|
||||||
queryLines.push('RETURNING')
|
|
||||||
const fields = this.renderFields(builder).map(x => indent(x))
|
|
||||||
.join(',\n')
|
|
||||||
|
|
||||||
queryLines.push(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryLines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderDelete(builder: AbstractBuilder<any>): string {
|
|
||||||
const rawSql = builder.appliedRawSql
|
|
||||||
if ( rawSql ) {
|
|
||||||
return rawSql
|
|
||||||
}
|
|
||||||
|
|
||||||
const indent = (item: string, level = 1) => Array(level + 1).fill('')
|
|
||||||
.join(' ') + item
|
|
||||||
const queryLines: string[] = []
|
|
||||||
|
|
||||||
// Add table source
|
|
||||||
const source = builder.querySource
|
|
||||||
if ( source ) {
|
|
||||||
queryLines.push('DELETE FROM ' + this.renderQuerySource(source))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add constraints
|
|
||||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
|
||||||
if ( wheres.trim() ) {
|
|
||||||
queryLines.push('WHERE')
|
|
||||||
queryLines.push(wheres)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add return fields
|
|
||||||
if ( builder.appliedFields?.length ) {
|
|
||||||
queryLines.push('RETURNING')
|
|
||||||
|
|
||||||
const fields = this.renderFields(builder).map(x => indent(x))
|
|
||||||
.join(',\n')
|
|
||||||
|
|
||||||
queryLines.push(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryLines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderConstraints(allConstraints: Constraint[], startingLevel = 1): string {
|
|
||||||
const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => {
|
|
||||||
const indent = Array(level * 2).fill(' ')
|
|
||||||
.join('')
|
|
||||||
const statements = []
|
|
||||||
|
|
||||||
for ( const constraint of constraints ) {
|
|
||||||
if ( isConstraintGroup(constraint) ) {
|
|
||||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
|
|
||||||
} 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}"`)
|
|
||||||
.join('.')
|
|
||||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
|
||||||
} else if ( constraint instanceof QuerySafeValue ) {
|
|
||||||
statements.push(`${indent}${statements.length < 1 ? '' : 'AND '}${constraint.toString()}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statements.filter(Boolean).join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return constraintsToSql(allConstraints)
|
|
||||||
}
|
|
||||||
|
|
||||||
public renderUpdateSet(data: {[key: string]: EscapeValue}): string {
|
|
||||||
const sets = []
|
|
||||||
for ( const key in data ) {
|
|
||||||
if ( !Object.prototype.hasOwnProperty.call(data, key) ) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sets.push(` "${key}" = ${this.escape(data[key])}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `SET\n${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(this.mapFieldType(type))}`
|
|
||||||
|
|
||||||
if ( builder.getLength() ) {
|
|
||||||
render += `(${builder.getLength()})`
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = builder.getDefaultValue()
|
|
||||||
if ( typeof defaultValue !== 'undefined' ) {
|
|
||||||
render += ` DEFAULT ${this.escape(defaultValue)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( builder.isPrimary() ) {
|
|
||||||
render += ` PRIMARY KEY`
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( type === FieldType.serial ) {
|
|
||||||
render += ` AUTOINCREMENT`
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( builder.isUnique() ) {
|
|
||||||
render += ` UNIQUE`
|
|
||||||
}
|
|
||||||
|
|
||||||
render += ` ${builder.isNullable() ? 'NULL' : 'NOT NULL'}`
|
|
||||||
return render
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapFieldType(type: FieldType): FieldType {
|
|
||||||
if ( type === FieldType.serial ) {
|
|
||||||
return FieldType.integer
|
|
||||||
}
|
|
||||||
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return queries.join(';\n\n') // fixme: sqlite3 in node doesn't properly support transactions
|
|
||||||
// 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')
|
|
||||||
}
|
|
||||||
|
|
||||||
public currentTimestamp(): QuerySafeValue {
|
|
||||||
return raw('CURRENT_TIMESTAMP')
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +1,82 @@
|
|||||||
import {Instantiable, FactoryProducer} from '../../di'
|
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 {Migrator} from './Migrator'
|
||||||
import {DatabaseMigrator} from './DatabaseMigrator'
|
import {DatabaseMigrator} from './DatabaseMigrator'
|
||||||
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dependency injection factory that matches the abstract Migrator class
|
* A dependency injection factory that matches the abstract Migrator class
|
||||||
* and produces an instance of the configured session driver implementation.
|
* and produces an instance of the configured session driver implementation.
|
||||||
*/
|
*/
|
||||||
|
@Injectable()
|
||||||
@FactoryProducer()
|
@FactoryProducer()
|
||||||
export class MigratorFactory extends ConfiguredSingletonFactory<Migrator> {
|
export class MigratorFactory extends AbstractFactory<Migrator> {
|
||||||
protected getConfigKey(): string {
|
@Inject()
|
||||||
return 'database.migrations.driver'
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({})
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): Migrator {
|
||||||
|
return new (this.getMigratorClass())()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDefaultImplementation(): Instantiable<Migrator> {
|
match(something: unknown): boolean {
|
||||||
return DatabaseMigrator
|
return something === Migrator
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getMigratorClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAbstractImplementation(): any {
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
return Migrator
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,287 +0,0 @@
|
|||||||
import {Model} from './Model'
|
|
||||||
import {collect, Collection, ErrorWithContext, Maybe} from '../../util'
|
|
||||||
import {HasSubtree} from './relation/HasSubtree'
|
|
||||||
import {Related} from './relation/decorators'
|
|
||||||
import {HasTreeParent} from './relation/HasTreeParent'
|
|
||||||
import {ModelBuilder} from './ModelBuilder'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model implementation with helpers for querying tree-structured data.
|
|
||||||
*
|
|
||||||
* This works by using a modified pre-order traversal to number the tree nodes
|
|
||||||
* with a left- and right-side numbers. For example:
|
|
||||||
*
|
|
||||||
* ```txt
|
|
||||||
* (1) A (14)
|
|
||||||
* |
|
|
||||||
* (2) B (9) (10) C (11) (12) D (14)
|
|
||||||
* |
|
|
||||||
* (3) E (6) (7) G (8)
|
|
||||||
* |
|
|
||||||
* (4) F (5)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* These numbers are stored, by default, in `left_num` and `right_num` columns.
|
|
||||||
* The `subtree()` method returns a `HasSubtree` relation which loads the subtree
|
|
||||||
* of a model and recursively nests the nodes.
|
|
||||||
*
|
|
||||||
* You can use the `children()` helper method to get a collection of the immediate
|
|
||||||
* children of this node, which also have the subtree set.
|
|
||||||
*
|
|
||||||
* To query the model without loading the entire subtree, use the `without()`
|
|
||||||
* method on the `ModelBuilder`. For example:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* MyModel.query<MyModel>().without('subtree')
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export abstract class TreeModel<T extends TreeModel<T>> extends Model<T> {
|
|
||||||
|
|
||||||
/** The table column where the left tree number is stored. */
|
|
||||||
public static readonly leftTreeField = 'left_num'
|
|
||||||
|
|
||||||
/** The table column where the right tree number is stored. */
|
|
||||||
public static readonly rightTreeField = 'right_num'
|
|
||||||
|
|
||||||
public static readonly parentIdField = 'parent_id'
|
|
||||||
|
|
||||||
/** @override to include the tree fields */
|
|
||||||
public static query<T2 extends Model<T2>>(): ModelBuilder<T2> {
|
|
||||||
return super.query<T2>()
|
|
||||||
.fields(this.rightTreeField, this.leftTreeField, this.parentIdField)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @override to eager-load the subtree by default
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected with: (keyof T)[] = ['subtree']
|
|
||||||
|
|
||||||
protected removedChildren: Collection<TreeModel<T>> = collect()
|
|
||||||
|
|
||||||
/** @override to include the tree fields */
|
|
||||||
public query(): ModelBuilder<T> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return super.query()
|
|
||||||
.fields(ctor.leftTreeField, ctor.rightTreeField, ctor.parentIdField)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the left tree number for this model. */
|
|
||||||
public leftTreeNum(): Maybe<number> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return this.getColumn(ctor.leftTreeField) as number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the right tree number for this model. */
|
|
||||||
public rightTreeNum(): Maybe<number> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return this.getColumn(ctor.rightTreeField) as number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the ID of this node's parent, if one exists. */
|
|
||||||
public parentId(): Maybe<number> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return this.getColumn(ctor.parentIdField) as number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if this node has no children. */
|
|
||||||
public isLeaf(): boolean {
|
|
||||||
const left = this.leftTreeNum()
|
|
||||||
const right = this.rightTreeNum()
|
|
||||||
return Boolean(left && right && (right - left === 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if the given `node` exists within the subtree of this node. */
|
|
||||||
public contains(node: TreeModel<T>): boolean {
|
|
||||||
const num = node.leftTreeNum()
|
|
||||||
const left = this.leftTreeNum()
|
|
||||||
const right = this.rightTreeNum()
|
|
||||||
return Boolean(num && left && right && (left < num && right > num))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The subtree nodes of this model, recursively nested. */
|
|
||||||
@Related()
|
|
||||||
public subtree(): HasSubtree<T> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return this.make<HasSubtree<T>>(HasSubtree, this, ctor.leftTreeField)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The parent node of this model, if one exists. */
|
|
||||||
@Related()
|
|
||||||
public parentNode(): HasTreeParent<T> {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
return this.make<HasTreeParent<T>>(HasTreeParent, this, ctor.leftTreeField, ctor.parentIdField)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the immediate children of this model. */
|
|
||||||
public children(): Collection<T> {
|
|
||||||
return this.subtree().getValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the parent of this model, if one exists. */
|
|
||||||
public parent(): Maybe<T> {
|
|
||||||
return this.parentNode().getValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
public root(): Maybe<TreeModel<T>> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
||||||
let parent: Maybe<TreeModel<T>> = this
|
|
||||||
while ( parent?.parent() ) {
|
|
||||||
parent = parent?.parent()
|
|
||||||
}
|
|
||||||
return parent
|
|
||||||
}
|
|
||||||
|
|
||||||
public rootOrFail(): TreeModel<T> {
|
|
||||||
const root = this.root()
|
|
||||||
if ( !root ) {
|
|
||||||
throw new ErrorWithContext('Unable to determine tree root', {
|
|
||||||
node: this,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the nearest node in the parental line that is an ancestor of both this node and the `other` node. */
|
|
||||||
public commonAncestorWith(other: TreeModel<T>): Maybe<TreeModel<T>> {
|
|
||||||
if ( this.contains(other) ) {
|
|
||||||
// Handle the special case when this node is an ancestor of the other.
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( other.contains(this) ) {
|
|
||||||
// Handle the special case when this node is a descendant of the other.
|
|
||||||
return other
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, walk up this node's ancestral line and try to find a shared ancestor.
|
|
||||||
// It's getting too anthropological up in here.
|
|
||||||
let parent = this.parent()
|
|
||||||
while ( parent ) {
|
|
||||||
if ( parent.contains(other) ) {
|
|
||||||
return parent
|
|
||||||
}
|
|
||||||
|
|
||||||
parent = parent.parent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the preorder traversal numbering for this node and its subtree.
|
|
||||||
* After renumbering, you probably also want to save the subtree nodes to persist
|
|
||||||
* the tree into the database.
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* await model.renumber().saveSubtree()
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public renumber(): this {
|
|
||||||
// Assume our leftTreeNum is -correct-.
|
|
||||||
// Given that, renumber the children recursively, then set our rightTreeNum
|
|
||||||
const myLeftNum = this.leftTreeNum()
|
|
||||||
if ( !myLeftNum ) {
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
let nextChildLeftNum = myLeftNum + 1
|
|
||||||
let myRightNum = myLeftNum + 1
|
|
||||||
|
|
||||||
this.children()
|
|
||||||
.each(child => {
|
|
||||||
child.setColumn(ctor.leftTreeField, nextChildLeftNum)
|
|
||||||
child.setColumn(ctor.parentIdField, this.key())
|
|
||||||
child.parentNode().setValue(this as unknown as T)
|
|
||||||
child.renumber()
|
|
||||||
myRightNum = (child.rightTreeNum() ?? 0) + 1
|
|
||||||
nextChildLeftNum = (child.rightTreeNum() ?? 0) + 1
|
|
||||||
})
|
|
||||||
|
|
||||||
this.setColumn(ctor.rightTreeField, myRightNum)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a flat collection of this node & its subtree nodes, in pre-order. */
|
|
||||||
public flatten(): Collection<TreeModel<T>> {
|
|
||||||
return this.flattenLevel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursive helper for `flatten()`. */
|
|
||||||
private flattenLevel(topLevel = true): Collection<TreeModel<T>> {
|
|
||||||
const flat = collect<TreeModel<T>>(topLevel ? [this] : [])
|
|
||||||
this.children().each(child => {
|
|
||||||
flat.push(child)
|
|
||||||
flat.concat(child.flattenLevel(false))
|
|
||||||
})
|
|
||||||
return flat
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call the `save()` method on all nodes in this node & its subtree. */
|
|
||||||
public async saveSubtree(): Promise<void> {
|
|
||||||
await this.save()
|
|
||||||
await this.removedChildren.awaitMapCall('saveSubtree')
|
|
||||||
this.removedChildren = collect()
|
|
||||||
await this.children()
|
|
||||||
.map(child => child.saveSubtree())
|
|
||||||
.awaitAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append the `other` node as a child of this node.
|
|
||||||
* Returns the common ancestor from which point the tree was renumbered.
|
|
||||||
* You should save the subtree after this.
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* await nodeA.appendChild(nodeB).saveSubtree()
|
|
||||||
* ```
|
|
||||||
* @param other
|
|
||||||
* @param before - if provided, `other` will be inserted before the `before` child of this node. Otherwise, it will be added as the last child.
|
|
||||||
*/
|
|
||||||
public appendChild(other: T, before: Maybe<T> = undefined): TreeModel<T> {
|
|
||||||
// Determine the index where we should insert the node in our children
|
|
||||||
const idx = (before ? this.children().search(before) : undefined) ?? this.children().length
|
|
||||||
|
|
||||||
// Insert the child at that index
|
|
||||||
this.subtree().appendSubtree(idx, other)
|
|
||||||
|
|
||||||
// If the child has a parent:
|
|
||||||
const parent = other.parent()
|
|
||||||
if ( parent ) {
|
|
||||||
// Remove the child from the parent's children
|
|
||||||
parent.subtree().removeSubtree(other)
|
|
||||||
|
|
||||||
// Get the common ancestor of the child's parent and this node
|
|
||||||
const ancestor = this.commonAncestorWith(other)
|
|
||||||
if ( ancestor ) {
|
|
||||||
// Renumber from that ancestor
|
|
||||||
return ancestor.renumber()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the child has no parent or a common ancestor could not be found,
|
|
||||||
// renumber the entire tree since the total number of nodes has changed
|
|
||||||
return this.rootOrFail().renumber()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove this node from the tree structure.
|
|
||||||
* If the node existed in the tree structure and was removed, this method
|
|
||||||
* returns the tree node that was renumbered. You should save the subtree after this.
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* await nodeA.removeFromTree().saveSubtree()
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public removeFromTree(): Maybe<TreeModel<T>> {
|
|
||||||
const parent = this.parent()
|
|
||||||
if ( parent ) {
|
|
||||||
const ctor = this.constructor as typeof TreeModel
|
|
||||||
parent.subtree().removeSubtree(this)
|
|
||||||
parent.removedChildren.push(this)
|
|
||||||
this.setColumn(ctor.leftTreeField, null)
|
|
||||||
this.setColumn(ctor.rightTreeField, null)
|
|
||||||
this.setColumn(ctor.parentIdField, null)
|
|
||||||
return this.rootOrFail().renumber()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,191 +0,0 @@
|
|||||||
import {TreeModel} from '../TreeModel'
|
|
||||||
import {Relation, RelationNotLoadedError} from './Relation'
|
|
||||||
import {collect, Collection, Maybe} from '../../../util'
|
|
||||||
import {RelationBuilder} from './RelationBuilder'
|
|
||||||
import {raw} from '../../dialect/SQLDialect'
|
|
||||||
import {AbstractBuilder} from '../../builder/AbstractBuilder'
|
|
||||||
import {ModelBuilder} from '../ModelBuilder'
|
|
||||||
import {Inject, Injectable} from '../../../di'
|
|
||||||
import {Logging} from '../../../service/Logging'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A relation that recursively loads the subtree of a model using
|
|
||||||
* modified preorder traversal.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HasSubtree<T extends TreeModel<T>> extends Relation<T, T, Collection<T>> {
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly logging!: Logging
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the relation is loaded, the immediate children of the node.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected instances: Maybe<Collection<T>>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
protected readonly model: T,
|
|
||||||
protected readonly leftTreeField: string,
|
|
||||||
) {
|
|
||||||
super(model, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
public flatten(): Collection<T> {
|
|
||||||
const children = this.getValue()
|
|
||||||
const subtrees = children.reduce((subtree, child) => subtree.concat(child.subtree().flatten()), collect())
|
|
||||||
return children.concat(subtrees)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Manually load the subtree. */
|
|
||||||
public async load(): Promise<void> {
|
|
||||||
this.setValue(await this.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get parentValue(): any {
|
|
||||||
return this.model.key()
|
|
||||||
}
|
|
||||||
|
|
||||||
public query(): RelationBuilder<T> {
|
|
||||||
return this.builder()
|
|
||||||
.tap(b => this.model.applyScopes(b))
|
|
||||||
.select(raw('*'))
|
|
||||||
.orderByAscending(this.leftTreeField)
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyScope(where: AbstractBuilder<T>): void {
|
|
||||||
const left = this.model.leftTreeNum()
|
|
||||||
const right = this.model.rightTreeNum()
|
|
||||||
if ( !left || !right ) {
|
|
||||||
where.whereMatchNone()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
where.where(this.leftTreeField, '>', left)
|
|
||||||
.where(this.leftTreeField, '<', right)
|
|
||||||
}
|
|
||||||
|
|
||||||
public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T> {
|
|
||||||
const query = this.model.query().without('subtree')
|
|
||||||
|
|
||||||
this.logging.debug(`Building eager query for parent: ${parentQuery}`)
|
|
||||||
this.logging.debug(result)
|
|
||||||
|
|
||||||
if ( result.isEmpty() ) {
|
|
||||||
return query.whereMatchNone()
|
|
||||||
}
|
|
||||||
|
|
||||||
result.each(inst => {
|
|
||||||
const left = inst.leftTreeNum()
|
|
||||||
const right = inst.rightTreeNum()
|
|
||||||
if ( !left || !right ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
query.orWhere(where => {
|
|
||||||
where.where(this.leftTreeField, '>', left)
|
|
||||||
.where(this.leftTreeField, '<', right)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
this.logging.debug(`Built eager query: ${query}`)
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
|
|
||||||
this.logging.debug('Matching possibly related: ' + possiblyRelated.length)
|
|
||||||
this.logging.verbose(possiblyRelated)
|
|
||||||
const modelLeft = this.model.leftTreeNum()
|
|
||||||
const modelRight = this.model.rightTreeNum()
|
|
||||||
if ( !modelLeft || !modelRight ) {
|
|
||||||
return collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
return possiblyRelated.filter(inst => {
|
|
||||||
const instLeft = inst.leftTreeNum()
|
|
||||||
return Boolean(instLeft && instLeft > modelLeft && instLeft < modelRight)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public appendSubtree(idx: number, subtree: T): void {
|
|
||||||
if ( !this.instances ) {
|
|
||||||
throw new RelationNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.instances = this.instances.put(idx, subtree)
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeSubtree(subtree: TreeModel<T>): void {
|
|
||||||
if ( !this.instances ) {
|
|
||||||
throw new RelationNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.instances = this.instances.filter(x => x.isNot(subtree))
|
|
||||||
}
|
|
||||||
|
|
||||||
public setValue(related: Collection<T>): void {
|
|
||||||
// `related` contains a flat collection of the subtree nodes, ordered by left key ascending
|
|
||||||
// We will loop through the related nodes and recursively call `setValue` for our immediate
|
|
||||||
// children to build the tree.
|
|
||||||
|
|
||||||
type ReduceState = {
|
|
||||||
currentChild: T,
|
|
||||||
currentSubtree: Collection<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const children = this.instances = collect()
|
|
||||||
const firstChild = related.pop()
|
|
||||||
if ( !firstChild ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalState = related.reduce<ReduceState>((state: ReduceState, node: T) => {
|
|
||||||
if ( state.currentChild.contains(node) ) {
|
|
||||||
// `node` belongs in the subtree of `currentChild`, not this node
|
|
||||||
state.currentSubtree.push(node)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
// We've hit the end of the subtree for `currentChild`, so set the child's
|
|
||||||
// subtree relation value and move on to the next child.
|
|
||||||
state.currentChild.subtree().setValue(state.currentSubtree)
|
|
||||||
children.push(state.currentChild)
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentChild: node,
|
|
||||||
currentSubtree: collect(),
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
currentChild: firstChild,
|
|
||||||
currentSubtree: collect(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Do this one last time, since the reducer isn't called for the last node in the collection
|
|
||||||
if ( finalState ) {
|
|
||||||
finalState.currentChild.subtree().setValue(finalState.currentSubtree)
|
|
||||||
children.push(finalState.currentChild)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the parent relation on the immediate children we identified
|
|
||||||
children.each(child => child.parentNode().setValue(this.model))
|
|
||||||
|
|
||||||
this.instances = children.sortBy(inst => inst.getOriginalValues()?.[this.leftTreeField])
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue(): Collection<T> {
|
|
||||||
if ( !this.instances ) {
|
|
||||||
throw new RelationNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instances
|
|
||||||
}
|
|
||||||
|
|
||||||
public isLoaded(): boolean {
|
|
||||||
return Boolean(this.instances)
|
|
||||||
}
|
|
||||||
|
|
||||||
public get(): Promise<Collection<T>> {
|
|
||||||
return this.fetch().collect()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import {Relation, RelationNotLoadedError} from './Relation'
|
|
||||||
import {TreeModel} from '../TreeModel'
|
|
||||||
import {RelationBuilder} from './RelationBuilder'
|
|
||||||
import {raw} from '../../dialect/SQLDialect'
|
|
||||||
import {AbstractBuilder} from '../../builder/AbstractBuilder'
|
|
||||||
import {ModelBuilder} from '../ModelBuilder'
|
|
||||||
import {Collection, Maybe} from '../../../util'
|
|
||||||
|
|
||||||
export class HasTreeParent<T extends TreeModel<T>> extends Relation<T, T, Maybe<T>> {
|
|
||||||
|
|
||||||
protected parentInstance?: T
|
|
||||||
|
|
||||||
protected loaded = false
|
|
||||||
|
|
||||||
protected constructor(
|
|
||||||
protected model: T,
|
|
||||||
protected readonly leftTreeField: string,
|
|
||||||
protected readonly parentIdField: string,
|
|
||||||
) {
|
|
||||||
super(model, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected get parentValue(): any {
|
|
||||||
return this.model.key()
|
|
||||||
}
|
|
||||||
|
|
||||||
public query(): RelationBuilder<T> {
|
|
||||||
return this.builder()
|
|
||||||
.tap(b => this.model.applyScopes(b))
|
|
||||||
.select(raw('*'))
|
|
||||||
.orderByAscending(this.leftTreeField)
|
|
||||||
}
|
|
||||||
|
|
||||||
public applyScope(where: AbstractBuilder<T>): void {
|
|
||||||
const parentId = this.model.parentId()
|
|
||||||
if ( !parentId ) {
|
|
||||||
where.whereMatchNone()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
where.where(this.parentIdField, '=', parentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
public buildEagerQuery(parentQuery: ModelBuilder<T>, result: Collection<T>): ModelBuilder<T> {
|
|
||||||
const parentIds = result.map(model => model.parentId()).whereDefined()
|
|
||||||
return this.model.query()
|
|
||||||
.without('subtree')
|
|
||||||
.whereIn(this.parentIdField, parentIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
public matchResults(possiblyRelated: Collection<T>): Collection<T> {
|
|
||||||
return possiblyRelated.filter(related => related.key() === this.model.parentId())
|
|
||||||
}
|
|
||||||
|
|
||||||
public setValue(related: Maybe<T>): void {
|
|
||||||
this.loaded = true
|
|
||||||
this.parentInstance = related
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue(): Maybe<T> {
|
|
||||||
if ( !this.loaded && this.model.parentId() ) {
|
|
||||||
throw new RelationNotLoadedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.parentInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
public isLoaded(): boolean {
|
|
||||||
return this.loaded || !this.model.parentId()
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get(): Promise<Maybe<T>> {
|
|
||||||
return this.fetch().first()
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue