8 Commits
0.5.0 ... 0.5.2

Author SHA1 Message Date
22cf6aa953 bump version
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is failing
2021-10-18 13:41:22 -05:00
b35eb8d6a1 Fix error throw
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 13:36:59 -05:00
9ee4c42e43 Error type fixes
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 13:03:28 -05:00
8d1dcc87fb Bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-10-18 12:49:56 -05:00
3efbfecf9d OAuth2 stuff
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 12:48:16 -05:00
a1d04d652e Implement basic login & registration forms
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 22:25:51 -05:00
5940b6e2b3 Fix circular dependencies in migrator
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 13:42:06 -05:00
074a3187eb Add support for jobs & queueables, migrations
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
- Create migration directives & migrators
- Modify Cache classes to support array manipulation
- Create Redis unit and RedisCache implementation
- Create Queueable base class and Queue class that uses Cache backend
2021-08-23 23:51:53 -05:00
78 changed files with 3021 additions and 1464 deletions

View File

@@ -103,10 +103,19 @@ steps:
event:
exclude: tag
- name: build module
- name: Install dependencies
image: glmdev/node-pnpm:latest
commands:
- pnpm i
- name: Lint code
image: glmdev/node-pnpm:latest
commands:
- pnpm lint
- name: build module
image: glmdev/node-pnpm:latest
commands:
- pnpm build
- mkdir artifacts
- tar czf artifacts/extollo-lib.tar.gz lib

55
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,55 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
</TypeScriptCodeStyleSettings>
<editorconfig>
<option name="ENABLED" value="false" />
</editorconfig>
<codeStyleSettings language="JavaScript">
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
<codeStyleSettings language="PHP">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
<option name="SMART_TABS" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script">
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

19
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

1
.idea/lib.iml generated
View File

@@ -4,5 +4,6 @@
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="extollo" />
</component>
</module>

1
.idea/modules.xml generated
View File

@@ -2,6 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
</modules>
</component>

2
.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.5.0",
"version": "0.5.2",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -12,6 +12,7 @@
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/ioredis": "^4.26.6",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1",
@@ -27,9 +28,11 @@
"cli-table": "^0.3.6",
"colors": "^1.4.0",
"dotenv": "^8.2.0",
"ioredis": "^4.27.6",
"mime-types": "^2.1.31",
"mkdirp": "^1.0.4",
"negotiator": "^0.6.2",
"node-fetch": "^3",
"pg": "^8.6.0",
"pluralize": "^8.0.0",
"pug": "^3.0.2",
@@ -45,9 +48,7 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint && rimraf lib",
"build": "tsc",
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js",
"prepare": "pnpm run build",
"docs:build": "typedoc --options typedoc.json",

2387
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import {ErrorWithContext} from '../util'
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
}

View File

@@ -1,10 +1,11 @@
import {Inject, Injectable} from '../di'
import {EventBus} from '../event/EventBus'
import {Awaitable, Maybe} from '../util'
import {Authenticatable, AuthenticatableRepository} from './types'
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
import {UserFlushedEvent} from './event/UserFlushedEvent'
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
import {Logging} from '../service/Logging'
/**
* Base-class for a context that authenticates users and manages security.
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
@Inject()
protected readonly bus!: EventBus
@Inject()
protected readonly logging!: Logging
/** The currently authenticated user, if one exists. */
private authenticatedUser?: Authenticatable
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
* unauthenticated implicitly.
* @param credentials
*/
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticateOnce(user)
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
* authentication will be persisted.
* @param credentials
*/
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticate(user)
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
*/
async resume(): Promise<void> {
const credentials = await this.getCredentials()
this.logging.debug('resume:')
this.logging.debug(credentials)
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
this.authenticatedUser = user
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
* Get the credentials for the current user from whatever storage medium
* the context's host provides.
*/
abstract getCredentials(): Awaitable<Record<string, string>>
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
/**
* Get the currently authenticated user, if one exists.
@@ -138,6 +144,8 @@ export abstract class SecurityContext {
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
this.logging.debug('hasUser?')
this.logging.debug(this.authenticatedUser)
return Boolean(this.authenticatedUser)
}
}

View File

@@ -0,0 +1,145 @@
import {Controller} from '../../http/Controller'
import {Inject, Injectable} from '../../di'
import {ResponseObject, Route} from '../../http/routing/Route'
import {Request} from '../../http/lifecycle/Request'
import {view} from '../../http/response/ViewResponseFactory'
import {ResponseFactory} from '../../http/response/ResponseFactory'
import {SecurityContext} from '../SecurityContext'
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
import {Routing} from '../../service/Routing'
import {Valid, ValidationError} from '../../forms'
import {AuthenticatableCredentials} from '../types'
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
import {Session} from '../../http/session/Session'
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
@Injectable()
export class BasicLoginController extends Controller {
public static routes({ enableRegistration = true } = {}): void {
Route.group('auth', () => {
Route.get('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getLogin()
})
.pre('@auth:guest')
.alias('@auth.login')
Route.post('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptLogin()
})
.pre('@auth:guest')
.alias('@auth.login.attempt')
Route.any('logout', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptLogout()
})
.pre('@auth:required')
.alias('@auth.logout')
if ( enableRegistration ) {
Route.get('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getRegistration()
})
.pre('@auth:guest')
.alias('@auth.register')
Route.post('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptRegister()
})
.pre('@auth:guest')
.alias('@auth.register.attempt')
}
}).pre('@auth:web')
}
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
public getLogin(): ResponseFactory {
return this.getLoginView()
}
public getRegistration(): ResponseFactory {
return this.getRegistrationView()
}
public async attemptLogin(): Promise<ResponseObject> {
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.attempt(data)
if ( user ) {
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
}
return this.getLoginView(['Invalid username/password.'])
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getLoginView(e.errors.all())
}
throw e
}
}
public async attemptLogout(): Promise<ResponseObject> {
await this.security.flush()
return this.getMessageView('You have been logged out.')
}
public async attemptRegister(): Promise<ResponseObject> {
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.repository.createByCredentials(data)
await this.security.authenticate(user)
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getRegistrationView(e.errors.all())
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
return this.getRegistrationView(['A user with that username already exists.'])
}
throw e
}
}
protected getLoginView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:login', {
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
errors,
})
}
protected getRegistrationView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:register', {
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
errors,
})
}
protected getMessageView(message: string): ResponseFactory {
return view('@extollo:auth:message', {
message,
})
}
}

View File

@@ -1,21 +1,17 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
export interface BasicLoginCredentials {
username: string,
password: string,
}
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
username: [
identifier: [
Is.required,
Str.lengthMin(1),
],
password: [
credential: [
Is.required,
Str.lengthMin(1),
],

View File

@@ -0,0 +1,22 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
Str.alphaNum,
],
credential: [
Is.required,
Str.lengthMin(8),
Str.confirmed,
],
}
}
}

View File

@@ -1,5 +1,6 @@
import {Instantiable} from '../di'
import {ORMUserRepository} from './orm/ORMUserRepository'
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
/**
* Inferface for type-checking the AuthenticatableRepositories values.
@@ -21,5 +22,8 @@ export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
export interface AuthConfig {
repositories: {
session: keyof AuthenticatableRepositoryMapping,
}
},
sources?: {
[key: string]: OAuth2LoginConfig,
},
}

View File

@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableRepository} from '../types'
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
/**
* Security context implementation that uses the session as storage.
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
super(repository, 'session')
}
getCredentials(): Awaitable<Record<string, string>> {
getCredentials(): Awaitable<AuthenticatableCredentials> {
return {
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
identifier: '',
credential: this.session.get('extollo.auth.securityIdentifier'),
}
}

View File

@@ -0,0 +1,95 @@
import {Controller} from '../../../http/Controller'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
import {Request} from '../../../http/lifecycle/Request'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {ErrorWithContext} from '../../../util'
import {OAuth2Repository} from './OAuth2Repository'
import {json} from '../../../http/response/JSONResponseFactory'
export interface OAuth2LoginConfig {
name: string,
clientId: string,
clientSecret: string,
redirectUrl: string,
authorizationCodeField: string,
tokenEndpoint: string,
tokenEndpointMapping?: {
clientId?: string,
clientSecret?: string,
grantType?: string,
codeKey?: string,
},
tokenEndpointResponseMapping?: {
token?: string,
expiresIn?: string,
expiresAt?: string,
},
userEndpoint: string,
userEndpointResponseMapping?: {
identifier?: string,
display?: string,
},
}
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
return (
Boolean(what)
&& typeof (what as any).name === 'string'
&& typeof (what as any).clientId === 'string'
&& typeof (what as any).clientSecret === 'string'
&& typeof (what as any).redirectUrl === 'string'
&& typeof (what as any).authorizationCodeField === 'string'
&& typeof (what as any).tokenEndpoint === 'string'
&& typeof (what as any).userEndpoint === 'string'
)
}
@Injectable()
export class OAuth2LoginController extends Controller {
public static routes(configName: string): void {
Route.group(`/auth/${configName}`, () => {
Route.get('login', (request: Request) => {
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
return controller.getLogin()
}).pre('@auth:guest')
}).pre('@auth:web')
}
@Inject()
protected readonly config!: Config
constructor(
protected readonly request: Request,
protected readonly configName: string,
) {
super(request)
}
public async getLogin(): Promise<ResponseObject> {
const repo = this.getRepository()
if ( repo.shouldRedirect() ) {
return repo.redirect()
}
// We were redirected from the auth source
const user = await repo.redeem()
return json(user)
}
protected getRepository(): OAuth2Repository {
return this.request.make(OAuth2Repository, this.getConfig())
}
protected getConfig(): OAuth2LoginConfig {
const config = this.config.get(`auth.sources.${this.configName}`)
if ( !isOAuth2LoginConfig(config) ) {
throw new ErrorWithContext('Invalid OAuth2 source config.', {
configName: this.configName,
config,
})
}
return config
}
}

View File

@@ -0,0 +1,156 @@
import {
Authenticatable,
AuthenticatableCredentials,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../../types'
import {Inject, Injectable} from '../../../di'
import {
Awaitable,
dataGetUnsafe,
fetch,
Maybe,
MethodNotSupportedError,
UniversalPath,
universalPath,
uuid4,
} from '../../../util'
import {OAuth2LoginConfig} from './OAuth2LoginController'
import {Session} from '../../../http/session/Session'
import {ResponseObject} from '../../../http/routing/Route'
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
import {Request} from '../../../http/lifecycle/Request'
import {Logging} from '../../../service/Logging'
import {OAuth2User} from './OAuth2User'
@Injectable()
export class OAuth2Repository implements AuthenticatableRepository {
@Inject()
protected readonly session!: Session
@Inject()
protected readonly request!: Request
@Inject()
protected readonly logging!: Logging
constructor(
protected readonly config: OAuth2LoginConfig,
) { }
public createByCredentials(): Awaitable<Authenticatable> {
throw new MethodNotSupportedError()
}
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
return this.getAuthenticatableFromBearer(credentials.credential)
}
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return undefined
}
public getRedirectUrl(state?: string): UniversalPath {
const url = universalPath(this.config.redirectUrl)
if ( state ) {
url.query.append('state', state)
}
return url
}
public getTokenEndpoint(): UniversalPath {
return universalPath(this.config.tokenEndpoint)
}
public getUserEndpoint(): UniversalPath {
return universalPath(this.config.userEndpoint)
}
public async redeem(): Promise<Maybe<OAuth2User>> {
if ( !this.stateIsValid() ) {
return // FIXME throw
}
const body = new URLSearchParams()
if ( this.config.tokenEndpointMapping ) {
if ( this.config.tokenEndpointMapping.clientId ) {
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
}
if ( this.config.tokenEndpointMapping.clientSecret ) {
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
}
if ( this.config.tokenEndpointMapping.codeKey ) {
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
}
if ( this.config.tokenEndpointMapping.grantType ) {
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
}
}
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
const response = await fetch(this.getTokenEndpoint().toRemote, {
method: 'post',
body: body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
this.logging.debug(data)
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
this.logging.debug(bearer)
if ( !bearer || typeof bearer !== 'string' ) {
throw new Error()
}
return this.getAuthenticatableFromBearer(bearer)
}
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
const response = await fetch(this.getUserEndpoint().toRemote, {
method: 'get',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearer}`,
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
return new OAuth2User(data, this.config)
}
public stateIsValid(): boolean {
const correctState = this.session.get('extollo.auth.oauth2.state', '')
const inputState = this.request.input('state') || ''
return correctState === inputState
}
public shouldRedirect(): boolean {
const codeField = this.config.authorizationCodeField
const code = this.request.input(codeField)
return !code
}
public async redirect(): Promise<ResponseObject> {
const state = uuid4()
await this.session.set('extollo.auth.oauth2.state', state)
return temporary(this.getRedirectUrl(state).toRemote)
}
}

50
src/auth/external/oauth2/OAuth2User.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
import {OAuth2LoginConfig} from './OAuth2LoginController'
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
export class OAuth2User implements Authenticatable {
protected displayField: string
protected identifierField: string
constructor(
protected data: {[key: string]: any},
config: OAuth2LoginConfig,
) {
this.displayField = config.userEndpointResponseMapping?.display || 'name'
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
}
getDisplayIdentifier(): string {
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
}
getIdentifier(): AuthenticatableIdentifier {
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
}
async dehydrate(): Promise<JSONState> {
return {
isOAuth2User: true,
data: this.data,
displayField: this.displayField,
identifierField: this.identifierField,
}
}
rehydrate(state: JSONState): Awaitable<void> {
if (
!state.isOAuth2User
|| typeof state.data !== 'object'
|| state.data === null
|| typeof state.displayField !== 'string'
|| typeof state.identifierField !== 'string'
) {
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
}
this.data = state.data
this.identifierField = state.identifierField
this.displayField = state.identifierField
}
}

View File

@@ -21,3 +21,6 @@ export * from './Authentication'
export * from './config'
export * from './basic-ui/BasicLoginFormRequest'
export * from './basic-ui/BasicLoginController'
export * from './external/oauth2/OAuth2LoginController'

View File

@@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
/** The user's first name. */
@Field(FieldType.varchar, 'first_name')
public firstName!: string
public firstName?: string
/** The user's last name. */
@Field(FieldType.varchar, 'last_name')
public lastName!: string
public lastName?: string
/** The hashed and salted password of the user. */
@Field(FieldType.varchar, 'password_hash')

View File

@@ -1,13 +1,22 @@
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
import {
Authenticatable,
AuthenticatableCredentials,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../types'
import {Awaitable, Maybe} from '../../util'
import {ORMUser} from './ORMUser'
import {Injectable} from '../../di'
import {Container, Inject, Injectable} from '../../di'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
@Inject('injector')
protected readonly injector!: Container
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
@@ -21,21 +30,36 @@ export class ORMUserRepository extends AuthenticatableRepository {
* If username/password are specified, look up the user and verify the password.
* @param credentials
*/
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
if ( credentials.securityIdentifier ) {
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
if ( !credentials.identifier && credentials.credential ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.securityIdentifier)
.where('username', '=', credentials.credential)
.first()
}
if ( credentials.username && credentials.password ) {
if ( credentials.identifier && credentials.credential ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.username)
.where('username', '=', credentials.identifier)
.first()
if ( user && await user.verifyPassword(credentials.password) ) {
if ( user && await user.verifyPassword(credentials.credential) ) {
return user
}
}
}
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
if ( await this.getByCredentials(credentials) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
identifier: credentials.identifier,
})
}
const user = <ORMUser> this.injector.make(ORMUser)
user.username = credentials.identifier
await user.setPassword(credentials.credential)
await user.save()
return user
}
}

View File

@@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
export interface AuthenticatableCredentials {
identifier: string,
credential: string,
}
/**
* Base class for entities that can be authenticated.
*/
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
* Returns the user if the credentials are valid.
* @param credentials
*/
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
}

View File

@@ -169,8 +169,11 @@ export abstract class Directive extends AppClass {
const optionValues = this.parseOptions(options, argv)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e) {
} catch (e: unknown) {
if ( e instanceof Error ) {
this.nativeOutput(e.message)
}
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
@@ -187,6 +190,7 @@ export abstract class Directive extends AppClass {
this.nativeOutput(` - ${e.context.value}`)
}
}
this.nativeOutput('\nUse --help for more info.')
}
}

23
src/cli/decorators.ts Normal file
View File

@@ -0,0 +1,23 @@
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
import {CommandLine} from './service'
import {Directive} from './Directive'
import {logIfDebugging} from '../util'
/**
* Register a class as a command-line Directive.
* The class must extend Directive.
* @constructor
*/
export const CLIDirective = (): ClassDecorator => {
return (target) => {
if ( isInstantiableOf(target, Directive) ) {
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
ContainerBlueprint.getContainerBlueprint()
.onResolve<CommandLine>(CommandLine, cli => {
cli.registerDirective(target as Instantiable<Directive>)
})
} else {
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
}
}
}

View File

@@ -1,4 +1,4 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
import {Factory} from './factory/Factory'
@@ -7,7 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
import NamedFactory from './factory/NamedFactory'
import SingletonFactory from './factory/SingletonFactory'
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
import {ContainerBlueprint} from './ContainerBlueprint'
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
@@ -17,18 +17,36 @@ export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resol
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Given a Container instance, apply the ContainerBlueprint to it.
* @param container
*/
public static realizeContainer<T extends Container>(container: T): T {
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
ContainerBlueprint.getContainerBlueprint()
.resolveConstructable()
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
ContainerBlueprint.getContainerBlueprint()
.resolveResolutionCallbacks()
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
container.onResolve(listener.key)
.then(value => listener.callback(value))
})
return container
}
/**
* Get the global instance of this container.
*/
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Container()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -48,6 +66,12 @@ export class Container {
*/
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
/**
* Collection of callbacks waiting for a dependency key to be resolved.
* @protected
*/
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
constructor() {
this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this)
@@ -172,6 +196,26 @@ export class Container {
return this.instances.where('key', '=', key).isNotEmpty()
}
/**
* Get a Promise that resolves the first time the given dependency key is resolved
* by the application. If it has already been resolved, the Promise will resolve immediately.
* @param key
*/
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
if ( this.hasInstance(key) ) {
return new Promise<T>(res => res(this.make<T>(key)))
}
// Otherwise, we haven't instantiated an instance with this key yet,
// so put it onto the waitlist.
return new Promise<T>(res => {
this.waitingResolveCallbacks.push({
key,
callback: (res as (t: unknown) => unknown),
})
})
}
/**
* Returns true if the container has a factory for the given key.
* @param {DependencyKey} key
@@ -234,6 +278,15 @@ export class Container {
value: newInstance,
})
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
if ( waiter.key === key ) {
waiter.callback(newInstance)
return false
}
return true
})
return newInstance
}

View File

@@ -1,9 +1,19 @@
import {DependencyKey, Instantiable} from './types'
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
import NamedFactory from './factory/NamedFactory'
import {AbstractFactory} from './factory/AbstractFactory'
import {Factory} from './factory/Factory'
import {ClosureFactory} from './factory/ClosureFactory'
/** Simple type alias for a callback to a container's onResolve method. */
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
/**
* Blueprint for newly-created containers.
*
* This is used to allow global helpers like `@Singleton()`
* or `@CLIDirective()` while still supporting multiple
* global Container instances at once.
*/
export class ContainerBlueprint {
private static instance?: ContainerBlueprint
@@ -17,6 +27,19 @@ export class ContainerBlueprint {
protected factories: (() => AbstractFactory<any>)[] = []
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
/**
* Register some factory class with the container. Should take no construction params.
* @param factory
*/
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
this.constructableFactories.push(factory)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
@@ -47,7 +70,38 @@ export class ContainerBlueprint {
return this
}
/**
* Get an array of factory instances in the blueprint.
*/
resolve(): AbstractFactory<any>[] {
return this.factories.map(x => x())
}
/**
* Register an onResolve callback to be added to all newly-created containers.
* @param key
* @param callback
*/
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
this.resolutionCallbacks.push({
key,
callback,
})
return this
}
/**
* Get an array of static Factory classes that need to be instantiated by
* the container itself.
*/
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
return [...this.constructableFactories]
}
/**
* Get an array of DependencyKey-callback pairs to register with new containers.
*/
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
return [...this.resolutionCallbacks]
}
}

View File

@@ -1,5 +1,5 @@
import 'reflect-metadata'
import {collect, Collection} from '../../util'
import {collect, Collection, logIfDebugging} from '../../util'
import {
DependencyKey,
DependencyRequirement,
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
* If a `key` is specified, that DependencyKey will be injected.
* Otherwise, the DependencyKey is inferred from the type annotation.
* @param key
* @param debug
* @constructor
*/
export const Inject = (key?: DependencyKey): PropertyDecorator => {
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
return (target, property) => {
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
if ( !propertyMetadata ) {
@@ -91,11 +92,18 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
if ( existing ) {
existing.key = key
} else {
propertyMetadata.push({ property,
key })
propertyMetadata.push({
property,
key,
debug,
})
}
}
if ( debug ) {
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
}
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
}
}
@@ -152,3 +160,15 @@ export const Singleton = (name?: string): ClassDecorator => {
}
}
}
/**
* Register a factory class directly with any created containers.
* @constructor
*/
export const FactoryProducer = (): ClassDecorator => {
return (target) => {
if ( isInstantiable(target) ) {
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
}
}
}

View File

@@ -23,6 +23,16 @@ export function isInstantiable<T>(what: unknown): what is Instantiable<T> {
)
}
/**
* Returns true if the given value is instantiable and, once instantiated,
* will create an instance of the given static class.
* @param what
* @param type
*/
export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): what is Instantiable<T> {
return isInstantiable(what) && what.prototype instanceof type
}
/**
* Type that identifies a value as a static class, even if it is not instantiable.
*/
@@ -41,6 +51,11 @@ export function isStaticClass<T, T2>(something: unknown): something is StaticCla
*/
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
/**
* A DependencyKey, but typed
*/
export type TypedDependencyKey<T> = Instantiable<T> | StaticClass<T, any> | string
/**
* Interface used to store dependency requirements by their place in the injectable
* target's parameters.
@@ -58,6 +73,7 @@ export interface DependencyRequirement {
export interface PropertyDependency {
key: DependencyKey,
property: string | symbol,
debug?: boolean,
}
/**

View File

@@ -1,4 +1,4 @@
import {ValidationResult, ValidatorFunction} from './types'
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
import {isJSON} from '../../util'
/**
@@ -221,6 +221,24 @@ function lengthMax(len: number): ValidatorFunction {
}
}
/**
* Validator function that requires the input value to match a `${field}Confirm` field's value.
* @param fieldName
* @param inputValue
* @param params
*/
function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult {
const confirmedFieldName = `${fieldName}Confirm`
if ( inputValue === params.data[confirmedFieldName] ) {
return { valid: true }
}
return {
valid: false,
message: `confirmation does not match`,
}
}
export const Str = {
alpha,
alphaNum,
@@ -242,4 +260,5 @@ export const Str = {
length,
lengthMin,
lengthMax,
confirmed,
}

View File

@@ -1,12 +1,12 @@
import {AppClass} from '../lifecycle/AppClass'
import {Request} from './lifecycle/Request'
import {Container} from '../di'
import {CanonicalItemClass} from '../support/CanonicalReceiver'
/**
* Base class for controllers that define methods that
* handle HTTP requests.
*/
export class Controller extends AppClass {
export class Controller extends CanonicalItemClass {
constructor(
protected readonly request: Request,
) {

View File

@@ -108,6 +108,7 @@ export class Request extends ScopedContainer implements DataContainer {
protected serverResponse: ServerResponse,
) {
super(Container.getContainer())
this.registerSingletonInstance(Request, this)
this.secure = Boolean((clientRequest.connection as TLSSocket).encrypted)
@@ -124,12 +125,6 @@ export class Request extends ScopedContainer implements DataContainer {
minor: clientRequest.httpVersionMinor,
}
this.register(Request)
this.instances.push({
key: Request,
value: this,
})
const parts = url.parse(this.url, true)
this.path = parts.pathname ?? '/'

View File

@@ -1,12 +1,12 @@
import {AppClass} from '../../lifecycle/AppClass'
import {Request} from '../lifecycle/Request'
import {ResponseObject} from './Route'
import {Container} from '../../di'
import {CanonicalItemClass} from '../../support/CanonicalReceiver'
/**
* Base class representing a middleware handler that can be applied to routes.
*/
export abstract class Middleware extends AppClass {
export abstract class Middleware extends CanonicalItemClass {
constructor(
/** The request that will be handled by this middleware. */
protected readonly request: Request,

View File

@@ -62,6 +62,9 @@ export * from './http/Controller'
export * from './http/servers/static'
export * from './support/CanonicalReceiver'
export * from './service/Canon'
export * from './service/Canonical'
export * from './service/CanonicalInstantiable'
export * from './service/CanonicalRecursive'
@@ -74,9 +77,14 @@ export * from './service/HTTPServer'
export * from './service/Routing'
export * from './service/Middlewares'
export * from './support/redis/Redis'
export * from './support/cache/MemoryCache'
export * from './support/cache/RedisCache'
export * from './support/cache/CacheFactory'
export * from './support/NodeModules'
export * from './support/queue/Queue'
export * from './service/Queueables'
export * from './views/ViewEngine'
export * from './views/ViewEngineFactory'

View File

@@ -1,4 +1,4 @@
import {Container, ContainerBlueprint} from '../di'
import {Container} from '../di'
import {
ErrorWithContext,
globalRegistry,
@@ -9,7 +9,6 @@ import {
universalPath,
UniversalPath,
} from '../util'
import {Logging} from '../service/Logging'
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
import {Unit, UnitStatus} from './Unit'
@@ -58,12 +57,7 @@ export class Application extends Container {
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
const container = Application.realizeContainer(new Application())
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -79,18 +73,12 @@ export class Application extends Container {
if ( existing instanceof Application ) {
return existing
} else if ( existing ) {
const app = new Application()
const app = Application.realizeContainer(new Application())
existing.cloneTo(app)
globalRegistry.setGlobal('extollo/injector', app)
return app
} else {
const app = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => app.registerFactory(factory))
const app = Application.realizeContainer(new Application())
globalRegistry.setGlobal('extollo/injector', app)
return app
}
@@ -271,9 +259,13 @@ export class Application extends Container {
try {
await this.up()
await this.down()
} catch (e) {
} catch (e: unknown) {
if ( e instanceof Error ) {
this.errorHandler(e)
}
throw e
}
}
/**
@@ -318,10 +310,15 @@ export class Application extends Container {
await unit.up()
unit.status = UnitStatus.Started
logging.info(`Started ${unit.constructor.name}.`)
} catch (e) {
} catch (e: unknown) {
unit.status = UnitStatus.Error
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
/**
@@ -339,7 +336,12 @@ export class Application extends Container {
logging.info(`Stopped ${unit.constructor.name}.`)
} catch (e) {
unit.status = UnitStatus.Error
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
}

View File

@@ -79,10 +79,13 @@ ${contextDisplay}
}
this.logging.error(errorString, true)
} catch (displayError) {
} catch (displayError: unknown) {
if ( displayError instanceof Error ) {
// The error display encountered an error...
// just throw the original so it makes it out
console.error('RunLevelErrorHandler encountered an error:', displayError.message) // eslint-disable-line no-console
}
throw operativeError
}
}

View File

@@ -1,39 +0,0 @@
import {Inject, Injectable} from '../di'
import {ConstraintType, DatabaseService, FieldType, Migration, Schema} from '../orm'
/**
* Migration that creates the sessions table used by the ORMSession backend.
*/
@Injectable()
export default class CreateSessionsTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
async up(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('sessions')
table.primaryKey('session_uuid', FieldType.varchar)
.required()
table.column('session_data')
.type(FieldType.json)
.required()
.default('{}')
table.constraint('session_uuid_ck')
.type(ConstraintType.Check)
.expression('LENGTH(session_uuid) > 0')
await schema.commit(table)
}
async down(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('sessions')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@@ -1,47 +0,0 @@
import {Inject, Injectable} from '../di'
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
/**
* Migration that creates the users table used by @extollo/lib.auth.
*/
@Injectable()
export default class CreateUsersTableMigration extends Migration {
@Inject()
protected readonly db!: DatabaseService
async up(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('users')
table.primaryKey('user_id')
.required()
table.column('first_name')
.type(FieldType.varchar)
.required()
table.column('last_name')
.type(FieldType.varchar)
.required()
table.column('password_hash')
.type(FieldType.text)
.nullable()
table.column('username')
.type(FieldType.varchar)
.required()
.unique()
await schema.commit(table)
}
async down(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('users')
table.dropIfExists()
await schema.commit(table)
}
}

View File

@@ -1,4 +1,4 @@
import {ErrorWithContext} from '../../util'
import {Awaitable, ErrorWithContext} from '../../util'
import {QueryResult} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
@@ -68,6 +68,13 @@ export abstract class Connection extends AppClass {
*/
public abstract schema(name?: string): Schema
/**
* Execute all queries logged to this connection during the closure
* as a transaction in the database.
* @param closure
*/
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
/**
* Fire a QueryExecutedEvent for the given query string.
* @param query

View File

@@ -2,7 +2,7 @@ import {Connection, ConnectionNotReadyError} from './Connection'
import {Client} from 'pg'
import {Inject} from '../../di'
import {QueryResult} from '../types'
import {collect} from '../../util'
import {Awaitable, collect} from '../../util'
import {SQLDialect} from '../dialect/SQLDialect'
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
import {Logging} from '../../service/Logging'
@@ -63,11 +63,26 @@ export class PostgresConnection extends Connection {
rowCount: result.rowCount,
}
} catch (e) {
if ( e instanceof Error ) {
throw this.app().errorWrapContext(e, {
query,
connection: this.name,
})
}
throw e
}
}
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
if ( !this.client ) {
throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
}
await this.client.query('BEGIN')
const result = await closure()
await this.client.query('COMMIT')
return result
}
public schema(name?: string): Schema {

View File

@@ -2,11 +2,13 @@ import {Directive, OptionDefinition} from '../../cli'
import {Injectable} from '../../di'
import {stringToPascal} from '../../util'
import {templateMigration} from '../template/migration'
import {CLIDirective} from '../../cli/decorators'
/**
* CLI directive that creates migration classes from template.
*/
@Injectable()
@CLIDirective()
export class CreateMigrationDirective extends Directive {
getDescription(): string {
return 'create a new migration'

View File

@@ -1,18 +1,20 @@
import {Directive, OptionDefinition} from '../../cli'
import {Container, Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Migrator} from '../migrations/Migrator'
import {Migrations} from '../services/Migrations'
import {Migrator} from '../migrations/Migrator'
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
import {CLIDirective} from '../../cli/decorators'
/**
* CLI directive that applies migrations using the default Migrator.
* @fixme Support dry run mode
*/
@Injectable()
@CLIDirective()
export class MigrateDirective extends Directive {
@Inject()
protected readonly bus!: EventBus

View File

@@ -7,12 +7,14 @@ import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrati
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
import {CLIDirective} from '../../cli/decorators'
/**
* CLI directive that undoes applied migrations using the default Migrator.
* @fixme Support dry run mode
*/
@Injectable()
@CLIDirective()
export class RollbackDirective extends Directive {
@Inject()
protected readonly bus!: EventBus

View File

@@ -30,19 +30,20 @@ export * from './schema/TableBuilder'
export * from './schema/Schema'
export * from './schema/PostgresSchema'
export * from './services/Migrations'
export * from './migrations/Migrator'
export * from './migrations/NothingToMigrateError'
export * from './migrations/events/MigrationEvent'
export * from './migrations/events/ApplyingMigrationEvent'
export * from './migrations/events/AppliedMigrationEvent'
export * from './migrations/events/RollingBackMigrationEvent'
export * from './migrations/events/RolledBackMigrationEvent'
export * from './migrations/Migration'
export * from './migrations/Migrator'
export * from './migrations/MigratorFactory'
export * from './migrations/DatabaseMigrator'
export * from './services/Database'
export * from './services/Models'
export * from './services/Migrations'
export * from './directive/CreateMigrationDirective'
export * from './directive/MigrateDirective'

View File

@@ -14,7 +14,7 @@ import {NothingToMigrateError} from './NothingToMigrateError'
*/
@Injectable()
export abstract class Migrator {
@Inject()
@Inject(Migrations, { debug: true })
protected readonly migrations!: Migrations
@Inject()

View File

@@ -4,7 +4,7 @@ import {
PropertyDependency,
isInstantiable,
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, Instantiable, Injectable, Inject, FactoryProducer,
} from '../../di'
import {Collection, ErrorWithContext} from '../../util'
import {Logging} from '../../service/Logging'
@@ -17,6 +17,7 @@ import {DatabaseMigrator} from './DatabaseMigrator'
* and produces an instance of the configured session driver implementation.
*/
@Injectable()
@FactoryProducer()
export class MigratorFactory extends AbstractFactory<Migrator> {
@Inject()
protected readonly logging!: Logging

View File

@@ -612,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
}
const row = this.buildInsertFieldObject()
this.logging.debug('Insert field object:')
this.logging.debug(row)
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
const result = await this.query()
@@ -635,6 +637,30 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
return this
}
/**
* Delete the current model from the database, if it exists.
*/
async delete(): Promise<void> {
if ( !this.exists() ) {
return
}
await this.query()
.where(this.qualifyKey(), '=', this.key())
.delete()
const ctor = this.constructor as typeof Model
const field = getFieldsMeta(this)
.firstWhere('databaseKey', '=', ctor.key)
if ( field ) {
delete (this as any)[field.modelKey]
return
}
delete (this as any)[ctor.key]
}
/**
* Cast this model to a simple object mapping model fields to their values.
*
@@ -784,10 +810,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
private buildInsertFieldObject(): EscapeValueObject {
const ctor = this.constructor as typeof Model
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
return getFieldsMeta(this)
.pipe()
.unless(ctor.populateKeyOnInsert, fields => {
return fields.where('modelKey', '!=', this.keyName())
return fields.where('databaseKey', '!=', this.keyName())
})
.get()
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])

View File

@@ -1,8 +1,11 @@
import {Model} from './Model'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
import {Instantiable} from '../../di'
import {Instantiable, StaticClass} from '../../di'
import {ModelResultIterable} from './ModelResultIterable'
import {Collection} from '../../util'
import {ConstraintOperator, ModelKey, ModelKeys} from '../types'
import {EscapeValue} from '../dialect/SQLDialect'
/**
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
@@ -10,7 +13,7 @@ import {ModelResultIterable} from './ModelResultIterable'
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
constructor(
/** The model class that is created for results of this query. */
protected readonly ModelClass: Instantiable<T>,
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
) {
super()
}
@@ -22,4 +25,45 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
public getResultIterable(): AbstractResultIterable<T> {
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
}
/**
* Apply a WHERE...IN... constraint on the primary key of the model.
* @param keys
*/
public whereKey(keys: ModelKeys): this {
return this.whereIn(
this.ModelClass.qualifyKey(),
this.normalizeModelKeys(keys),
)
}
/**
* Apply a where constraint on the column corresponding the the specified
* property on the model.
* @param propertyName
* @param operator
* @param operand
*/
public whereProperty(propertyName: string, operator: ConstraintOperator, operand?: EscapeValue): this {
return this.where(
this.ModelClass.propertyToColumn(propertyName),
operator,
operand,
)
}
/**
* Given some format of keys of the model, try to normalize them to a flat array.
* @param keys
* @protected
*/
protected normalizeModelKeys(keys: ModelKeys): ModelKey[] {
if ( Array.isArray(keys) ) {
return keys
} else if ( keys instanceof Collection ) {
return keys.all()
}
return [keys]
}
}

View File

@@ -164,12 +164,8 @@ export class PostgresSchema extends Schema {
.pluck<string>('column_name')
.each(col => idx.field(col))
})
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
idx.primary()
})
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
idx.unique()
})
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())
.when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique())
.get()
.flagAsExistingInSchema()
}

View File

@@ -2,22 +2,15 @@ import {Inject, Singleton} from '../../di'
import {CanonicalInstantiable} from '../../service/CanonicalInstantiable'
import {Migration} from '../migrations/Migration'
import {CanonicalDefinition, CanonicalResolver} from '../../service/Canonical'
import {Migrator} from '../migrations/Migrator'
import {UniversalPath} from '../../util'
import {lib} from '../../lib'
import {CommandLine} from '../../cli'
import {MigrateDirective} from '../directive/MigrateDirective'
import {RollbackDirective} from '../directive/RollbackDirective'
import {CreateMigrationDirective} from '../directive/CreateMigrationDirective'
/**
* Service unit that loads and instantiates migration classes.
*/
@Singleton()
export class Migrations extends CanonicalInstantiable<Migration> {
@Inject()
protected readonly migrator!: Migrator
@Inject()
protected readonly cli!: CommandLine
@@ -38,11 +31,6 @@ export class Migrations extends CanonicalInstantiable<Migration> {
const basePath = lib().concat('migrations')
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)
this.registerNamespace('@extollo', resolver)
// Register the migrate CLI directives
this.cli.registerDirective(MigrateDirective)
this.cli.registerDirective(RollbackDirective)
this.cli.registerDirective(CreateMigrationDirective)
}
async initCanonicalItem(definition: CanonicalDefinition): Promise<Migration> {

View File

@@ -1,6 +1,8 @@
import {Model} from '../model/Model'
import {Field} from '../model/Field'
import {FieldType} from '../types'
import {Maybe} from '../../util'
import {ModelBuilder} from '../model/ModelBuilder'
/**
* A model instance which stores records from the ORMCache driver.
@@ -18,4 +20,15 @@ export class CacheModel extends Model<CacheModel> {
@Field(FieldType.timestamp, 'cache_expires')
public cacheExpires?: Date;
public static withCacheKey(key: string): ModelBuilder<CacheModel> {
return this.query<CacheModel>()
.whereKey(key)
.whereProperty('cacheExpires', '>', new Date())
}
public static getCacheKey(key: string): Promise<Maybe<CacheModel>> {
return this.withCacheKey(key)
.first()
}
}

View File

@@ -1,5 +1,5 @@
import {Container} from '../../di'
import {Cache} from '../../util'
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
import {CacheModel} from './CacheModel'
/**
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
*/
export class ORMCache extends Cache {
public async fetch(key: string): Promise<string | undefined> {
const model = await CacheModel.query<CacheModel>()
.where(CacheModel.qualifyKey(), '=', key)
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
.first()
if ( model ) {
return model.cacheValue
}
return (await CacheModel.getCacheKey(key))?.cacheValue
}
public async put(key: string, value: string, expires?: Date): Promise<void> {
@@ -31,15 +24,103 @@ export class ORMCache extends Cache {
}
public async has(key: string): Promise<boolean> {
return CacheModel.query()
.where(CacheModel.qualifyKey(), '=', key)
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
return CacheModel.withCacheKey(key)
.exists()
}
public async drop(key: string): Promise<void> {
await CacheModel.query()
.where(CacheModel.qualifyKey(), '=', key)
.whereKey(key)
.delete()
}
public async pop(key: string): Promise<string> {
return CacheModel.getConnection()
.asTransaction<string>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
throw new ErrorWithContext('Cannot pop cache value: key does not exist.', {
key,
})
}
await model.delete()
return model.cacheValue
})
}
public increment(key: string, amount = 1): Awaitable<number> {
return CacheModel.getConnection()
.asTransaction<number>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, String(amount))
return amount
}
model.cacheValue = String(parseInt(model.cacheValue, 10) + amount)
await model.save()
return parseInt(model.cacheValue, 10)
})
}
public decrement(key: string, amount = 1): Awaitable<number> {
return CacheModel.getConnection()
.asTransaction<number>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, String(-amount))
return amount
}
model.cacheValue = String(parseInt(model.cacheValue, 10) - amount)
await model.save()
return parseInt(model.cacheValue, 10)
})
}
public async arrayPush(key: string, value: string): Promise<void> {
await CacheModel.getConnection()
.asTransaction<void>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, JSON.stringify([value]))
return
}
const cacheValue = JSON.parse(model.cacheValue)
if ( !Array.isArray(cacheValue) ) {
throw new ErrorWithContext('Cannot push value to non-array.', {
key,
})
}
cacheValue.push(value)
model.cacheValue = JSON.stringify(cacheValue)
})
throw new Error('Method not implemented.')
}
public async arrayPop(key: string): Promise<Maybe<string>> {
return CacheModel.getConnection()
.asTransaction<Maybe<string>>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
return
}
const cacheValue = JSON.parse(model.cacheValue)
if ( !Array.isArray(cacheValue) ) {
throw new ErrorWithContext('Cannot pop value from non-array.', {
key,
})
}
const value = cacheValue.pop()
model.cacheValue = JSON.stringify(cacheValue)
await model.save()
return value
})
}
}

View File

@@ -6,9 +6,11 @@ import {FieldType} from '../types'
* Model used to fetch & store sessions from the ORMSession driver.
*/
export class SessionModel extends Model<SessionModel> {
protected static table = 'sessions'; // FIXME allow configuring
protected static table = 'sessions' // FIXME allow configuring
protected static key = 'session_uuid';
protected static key = 'session_uuid'
protected static populateKeyOnInsert = true
@Field(FieldType.varchar, 'session_uuid')
public uuid!: string;

View File

@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
*/
export type ModelKey = string | number
/**
* Collection of keys of a set of models.
*/
export type ModelKeys = ModelKey | ModelKey[] | Collection<ModelKey>
/**
* Interface for the result of a query execution.
*/

View File

@@ -8,5 +8,9 @@ block content
each error in errors
p.form-error-message #{error}
if formAction
form(method='post' enctype='multipart/form-data' action=formAction)
block form
else
form(method='post' enctype='multipart/form-data')
block form

View File

@@ -8,11 +8,11 @@ block heading
block form
.form-label-group
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
label(for='inputPassword') Password
@@ -21,4 +21,4 @@ block form
.text-center
span.small Need an account?&nbsp;
a(href='./register') Register here.
a(href=named('@auth.register')) Register here.

View File

@@ -0,0 +1,26 @@
extends ./form
block head
title Register | #{config('app.name', 'Extollo')}
block heading
| Register to continue
block form
.form-label-group
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
label(for='inputPassword') Password
.form-label-group
input#inputPasswordConfirm.form-control(type='password' name='credentialConfirm' required placeholder='Confirm Password')
label(for='inputPassword') Confirm Password
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
.text-center
span.small Have an account?&nbsp;
a(href=named('@auth.login')) Login here.

View File

@@ -1,5 +1,6 @@
import {Canonical} from './Canonical'
import {Singleton} from '../di'
import {Maybe} from '../util'
/**
* Error throw when a duplicate canonical key is registered.
@@ -46,6 +47,17 @@ export class Canon {
return this.resources[key] as Canonical<T>
}
/**
* Get a canonical item from a fully-qualified canonical name.
* This is just a quality-of-life wrapper around `this.resource(...).get(...)`.
* @param key
*/
getFromFullyQualified(key: string): Maybe<any> {
const [namespace, ...parts] = key.split('::')
const unqualified = parts.join('::')
return this.resource(namespace).get(unqualified)
}
/**
* Register a canonical resource.
* @param {Canonical} unit

View File

@@ -7,6 +7,7 @@ import {Logging} from './Logging'
import {Inject} from '../di'
import * as nodePath from 'path'
import {Unit} from '../lifecycle/Unit'
import {isCanonicalReceiver} from '../support/CanonicalReceiver'
/**
* Interface describing a definition of a single canonical item loaded from the app.
@@ -228,7 +229,16 @@ export abstract class Canonical<T> extends Unit {
const definition = await this.buildCanonicalDefinition(entry)
this.logging.verbose(`Registering canonical ${this.canonicalItem} "${definition.canonicalName}" from ${entry}`)
this.loadedItems[definition.canonicalName] = await this.initCanonicalItem(definition)
const resolvedItem = await this.initCanonicalItem(definition)
if ( isCanonicalReceiver(resolvedItem) ) {
resolvedItem.setCanonicalResolver(
`${this.canonicalItems}::${definition.canonicalName}`,
definition.canonicalName,
)
}
this.loadedItems[definition.canonicalName] = resolvedItem
}
this.canon.registerCanonical(this)

View File

@@ -1,5 +1,5 @@
import {Inject, Singleton} from '../di'
import {HTTPStatus, withTimeout} from '../util'
import {ErrorWithContext, HTTPStatus, withTimeout} from '../util'
import {Unit} from '../lifecycle/Unit'
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
import {Logging} from './Logging'
@@ -82,7 +82,8 @@ export class HTTPServer extends Unit {
}
public get handler(): RequestListener {
const timeout = this.config.get('server.timeout', 10000)
// const timeout = this.config.get('server.timeout', 10000)
const timeout = 0 // temporarily disable this because it is causing problems
return async (request: IncomingMessage, response: ServerResponse) => {
const extolloReq = new Request(request, response)
@@ -113,9 +114,13 @@ export class HTTPServer extends Unit {
try {
await this.kernel.handle(extolloReq)
} catch (e) {
if ( e instanceof Error ) {
await error(e).write(extolloReq)
}
await error(new ErrorWithContext('Unknown error occurred.', { e }))
}
await extolloReq.response.send()
}
}

25
src/service/Queueables.ts Normal file
View File

@@ -0,0 +1,25 @@
import {CanonicalStatic} from './CanonicalStatic'
import {Singleton, Instantiable, StaticClass} from '../di'
import {CanonicalDefinition} from './Canonical'
import {Queueable} from '../support/queue/Queue'
/**
* A canonical unit that resolves Queueable classes from `app/queueables`.
*/
@Singleton()
export class Queueables extends CanonicalStatic<Queueable, Instantiable<Queueable>> {
protected appPath = ['queueables']
protected canonicalItem = 'job'
protected suffix = '.job.js'
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Queueable, Instantiable<Queueable>>> {
const item = await super.initCanonicalItem(definition)
if ( !(item.prototype instanceof Queueable) ) {
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.Queueable.`)
}
return item
}
}

View File

@@ -0,0 +1,61 @@
import {AppClass} from '../lifecycle/AppClass'
/**
* Interface for a class that receives its canonical resolver names upon load.
*/
export interface CanonicalReceiver {
setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void
getCanonicalResolver(): string | undefined
getFullyQualifiedCanonicalResolver(): string | undefined
}
/**
* Function that checks whether a given value satisfies the CanonicalReceiver interface.
* @param something
*/
export function isCanonicalReceiver(something: unknown): something is CanonicalReceiver {
return (
typeof something === 'function'
&& typeof (something as any).setCanonicalResolver === 'function'
&& (something as any).setCanonicalResolver.length >= 1
&& typeof (something as any).getCanonicalResolver === 'function'
&& (something as any).getCanonicalResolver.length === 0
)
}
/**
* Base class for canonical items that implements the CanonicalReceiver interface.
* That is, `isCanonicalReceiver(CanonicalItemClass) === true`.
*/
export class CanonicalItemClass extends AppClass {
/** The type-prefixed canonical resolver of this class, set by the startup unit. */
private static canonFullyQualifiedResolver?: string
/** The unqualified canonical resolver of this class, set by the startup unit. */
private static canonUnqualifiedResolver?: string
/**
* Sets the fully- and un-qualified canonical resolver strings. Intended for use
* by the Canonical unit.
* @param fullyQualifiedResolver
* @param unqualifiedResolver
*/
public static setCanonicalResolver(fullyQualifiedResolver: string, unqualifiedResolver: string): void {
this.canonFullyQualifiedResolver = fullyQualifiedResolver
this.canonUnqualifiedResolver = unqualifiedResolver
}
/**
* Get the fully-qualified canonical resolver of this class, if one has been set.
*/
public static getFullyQualifiedCanonicalResolver(): string | undefined {
return this.canonFullyQualifiedResolver
}
/**
* Get the unqualified canonical resolver of this class, if one has been set.
*/
public static getCanonicalResolver(): string | undefined {
return this.canonUnqualifiedResolver
}
}

View File

@@ -8,7 +8,10 @@ export class MemoryCache extends Cache {
/** Static collection of in-memory cache items. */
private static cacheItems: Collection<{key: string, value: string, expires?: Date}> = new Collection<{key: string; value: string, expires?: Date}>()
public fetch(key: string): Awaitable<string|undefined> {
/** Static collection of in-memory arrays. */
private static cacheArrays: Collection<{key: string, values: string[]}> = new Collection<{key: string; values: string[]}>()
public fetch(key: string): string|undefined {
const now = new Date()
return MemoryCache.cacheItems
.where('key', '=', key)
@@ -41,4 +44,41 @@ export class MemoryCache extends Cache {
public drop(key: string): Awaitable<void> {
MemoryCache.cacheItems = MemoryCache.cacheItems.where('key', '!=', key)
}
public decrement(key: string, amount = 1): Awaitable<number | undefined> {
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) - amount
this.put(key, String(nextValue))
return nextValue
}
public increment(key: string, amount = 1): Awaitable<number | undefined> {
const nextValue = (parseInt(this.fetch(key) ?? '0', 10) ?? 0) + amount
this.put(key, String(nextValue))
return nextValue
}
public pop(key: string): Awaitable<string | undefined> {
const value = this.fetch(key)
this.drop(key)
return value
}
public arrayPop(key: string): Awaitable<string | undefined> {
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
if ( arr ) {
return arr.values.shift()
}
}
public arrayPush(key: string, value: string): Awaitable<void> {
const arr = MemoryCache.cacheArrays.firstWhere('key', '=', key)
if ( arr ) {
arr.values.push(value)
} else {
MemoryCache.cacheArrays.push({
key,
values: [value],
})
}
}
}

85
src/support/cache/RedisCache.ts vendored Normal file
View File

@@ -0,0 +1,85 @@
import {Cache, Maybe} from '../../util'
import {Inject, Injectable} from '../../di'
import {Redis} from '../redis/Redis'
/**
* Redis-driven Cache implementation.
*/
@Injectable()
export class RedisCache extends Cache {
/** The Redis service. */
@Inject()
protected readonly redis!: Redis
async arrayPop(key: string): Promise<string | undefined> {
return this.redis.pipe()
.tap(redis => redis.lpop(key))
.resolve()
}
async arrayPush(key: string, value: string): Promise<void> {
await this.redis.pipe()
.tap(redis => redis.rpush(key, value))
.resolve()
}
async decrement(key: string, amount?: number): Promise<number | undefined> {
return this.redis.pipe()
.tap(redis => redis.decrby(key, amount ?? 1))
.resolve()
}
async increment(key: string, amount?: number): Promise<number | undefined> {
return this.redis.pipe()
.tap(redis => redis.incrby(key, amount ?? 1))
.resolve()
}
async drop(key: string): Promise<void> {
await this.redis.pipe()
.tap(redis => redis.del(key))
.resolve()
}
async fetch(key: string): Promise<string | undefined> {
return this.redis.pipe()
.tap(redis => redis.get(key))
.tap(value => value ?? undefined)
.resolve()
}
async has(key: string): Promise<boolean> {
return this.redis.pipe()
.tap(redis => redis.exists(key))
.tap(numExisting => numExisting > 0)
.resolve()
}
pop(key: string): Promise<Maybe<string>> {
return new Promise<Maybe<string>>((res, rej) => {
this.redis.pipe()
.tap(redis => {
redis.multi()
.get(key, (err, value) => {
if ( err ) {
rej(err)
} else {
res(value)
}
})
.del(key)
})
})
}
async put(key: string, value: string, expires?: Date): Promise<void> {
await this.redis.multi()
.tap(redis => redis.set(key, value))
.when(Boolean(expires), redis => {
const seconds = Math.round(((new Date()).getTime() - expires!.getTime()) / 1000) // eslint-disable-line @typescript-eslint/no-non-null-assertion
return redis.expire(key, seconds)
})
.tap(pipeline => pipeline.exec())
.resolve()
}
}

190
src/support/queue/Queue.ts Normal file
View File

@@ -0,0 +1,190 @@
import {Awaitable, ErrorWithContext, JSONState, Maybe, Rehydratable, Cache} from '../../util'
import {CanonicalItemClass} from '../CanonicalReceiver'
import {Container, Inject, Injectable, isInstantiable} from '../../di'
import {Canon} from '../../service/Canon'
/** Type annotation for a Queueable that should be pushed onto a queue. */
export type ShouldQueue<T> = T & Queueable
/**
* Base class for an object that can be pushed to/popped from a queue.
*/
export abstract class Queueable extends CanonicalItemClass implements Rehydratable {
abstract dehydrate(): Awaitable<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
/**
* When the item is popped from the queue, this method is called.
*/
public abstract execute(): Awaitable<void>
/**
* Determine whether the object should be pushed to the queue or not.
*/
public shouldQueue(): boolean {
return true
}
/**
* The name of the queue where this object should be pushed by default.
*/
public defaultQueue(): string {
return 'default'
}
/**
* Get the canonical resolver so we can re-instantiate this class from the queue.
* Throw an error if it could not be determined.
*/
public getFullyQualifiedCanonicalResolver(): string {
const resolver = (this.constructor as typeof Queueable).getFullyQualifiedCanonicalResolver()
if ( !resolver ) {
throw new ErrorWithContext('Cannot push Queueable onto queue: missing canonical resolver.')
}
return resolver
}
}
/**
* Truth function that returns true if an object implements the same interface as Queueable.
* This is done in case some external library needs to be incorporated as the base class for
* a Queueable, and cannot be made to extend Queueable.
* @param something
*/
export function isQueueable(something: unknown): something is Queueable {
if ( something instanceof Queueable ) {
return true
}
return (
typeof something === 'function'
&& typeof (something as any).dehydrate === 'function'
&& typeof (something as any).rehydrate === 'function'
&& typeof (something as any).shouldQueue === 'function'
&& typeof (something as any).defaultQueue === 'function'
&& typeof (something as any).getFullyQualifiedCanonicalResolver === 'function'
)
}
/**
* Truth function that returns true if the given object is Queueable and wants to be
* pushed onto the queue.
* @param something
*/
export function shouldQueue<T>(something: T): something is ShouldQueue<T> {
return isQueueable(something) && something.shouldQueue()
}
/**
* A multi-node queue that accepts & reinstantiates Queueables.
*
* @example
* There are several queue backends your application may use. These are
* configured via the `queue` config. To get the default queue, however,
* use this class as a DI token:
* ```ts
* this.container().make<Queue>(Queue)
* ```
*
* This will resolve the concrete implementation configured by your app.
*/
@Injectable()
export class Queue {
@Inject()
protected readonly cache!: Cache
@Inject()
protected readonly canon!: Canon
@Inject('injector')
protected readonly injector!: Container
constructor(
public readonly name: string,
) { }
public get queueIdentifier(): string {
return `extollo__queue__${this.name}`
}
/** Get the number of items waiting in the queue. */
// public abstract length(): Awaitable<number>
/** Push a new queueable onto the queue. */
public async push(item: ShouldQueue<Queueable>): Promise<void> {
const data = {
q: true,
r: item.getFullyQualifiedCanonicalResolver(),
d: await item.dehydrate(),
}
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(data))
}
/** Remove and return a queueable from the queue. */
public async pop(): Promise<Maybe<Queueable>> {
const item = await this.cache.arrayPop(this.queueIdentifier)
if ( !item ) {
return
}
const data = JSON.parse(item)
if ( !data.q || !data.r ) {
throw new ErrorWithContext('Cannot pop Queueable: payload is invalid.', {
data,
queueName: this.name,
queueIdentifier: this.queueIdentifier,
})
}
const canonicalItem = this.canon.getFromFullyQualified(data.r)
if ( !canonicalItem ) {
throw new ErrorWithContext('Cannot pop Queueable: canonical name is not resolvable', {
data,
queueName: this.name,
queueIdentifier: this.queueIdentifier,
canonicalName: data.r,
})
}
if ( !isInstantiable(canonicalItem) ) {
throw new ErrorWithContext('Cannot pop Queueable: canonical item is not instantiable', {
data,
canonicalItem,
queueName: this.name,
queueIdentifier: this.queueIdentifier,
canonicalName: data.r,
})
}
const instance = this.injector.make(canonicalItem)
if ( !isQueueable(instance) ) {
throw new ErrorWithContext('Cannot pop Queueable: canonical item instance is not Queueable', {
data,
canonicalItem,
instance,
queueName: this.name,
queueIdentifier: this.queueIdentifier,
canonicalName: data.r,
})
}
await instance.rehydrate(data.d)
return instance
}
/** Push a raw payload onto the queue. */
public async pushRaw(item: JSONState): Promise<void> {
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(item))
}
/** Remove and return a raw payload from the queue. */
public async popRaw(): Promise<Maybe<JSONState>> {
const item = await this.cache.arrayPop(this.queueIdentifier)
if ( item ) {
return JSON.parse(item)
}
}
}

View File

@@ -0,0 +1,75 @@
import {Inject, Singleton} from '../../di'
import {Config} from '../../service/Config'
import * as IORedis from 'ioredis'
import {RedisOptions} from 'ioredis'
import {Logging} from '../../service/Logging'
import {Unit} from '../../lifecycle/Unit'
import {AsyncPipe} from '../../util'
export {RedisOptions} from 'ioredis'
/**
* Unit that loads configuration for and manages instantiation
* of an IORedis connection.
*/
@Singleton()
export class Redis extends Unit {
/** The config service. */
@Inject()
protected readonly config!: Config
/** The loggers. */
@Inject()
protected readonly logging!: Logging
/**
* The instantiated connection, if one exists.
* @private
*/
private connection?: IORedis.Redis
async up(): Promise<void> {
this.logging.info('Attempting initial connection to Redis...')
this.logging.debug('Config:')
this.logging.debug(Config)
this.logging.debug(this.config)
await this.getConnection()
}
async down(): Promise<void> {
this.logging.info('Disconnecting Redis...')
if ( this.connection?.status === 'ready' ) {
await this.connection.disconnect()
}
}
/**
* Get the IORedis connection instance.
*/
public async getConnection(): Promise<IORedis.Redis> {
if ( !this.connection ) {
const options = this.config.get('redis.connection') as RedisOptions
this.logging.verbose(options)
this.connection = new IORedis(options)
}
return this.connection
}
/**
* Get the IORedis connection in an AsyncPipe.
*/
public pipe(): AsyncPipe<IORedis.Redis> {
return new AsyncPipe<IORedis.Redis>(() => this.getConnection())
}
/**
* Get an IORedis.Pipeline instance in an AsyncPipe.
*/
public multi(): AsyncPipe<IORedis.Pipeline> {
return this.pipe()
.tap(redis => {
return redis.multi()
})
}
}

View File

@@ -15,8 +15,9 @@ export abstract class Cache {
* Store the given value in the cache by key.
* @param {string} key
* @param {string} value
* @param expires
*/
public abstract put(key: string, value: string): Awaitable<void>;
public abstract put(key: string, value: string, expires?: Date): Awaitable<void>;
/**
* Check if the cache has the given key.
@@ -30,4 +31,38 @@ export abstract class Cache {
* @param {string} key
*/
public abstract drop(key: string): Awaitable<void>;
/**
* Fetch an item from the cache by key, and then remove it.
* @param key
*/
public abstract pop(key: string): Awaitable<string|undefined>;
/**
* Increment a key in the cache by a given amount.
* @param key
* @param amount
*/
public abstract increment(key: string, amount?: number): Awaitable<number|undefined>;
/**
* Decrement a key in the cache by a given amount.
* @param key
* @param amount
*/
public abstract decrement(key: string, amount?: number): Awaitable<number|undefined>;
/**
* Push an item onto the end an array-like key.
* @param key
* @param value
*/
public abstract arrayPush(key: string, value: string): Awaitable<void>;
/**
* Remove and return an item from the beginning of an array-like key.
* @param key
* @param value
*/
public abstract arrayPop(key: string): Awaitable<string|undefined>;
}

View File

@@ -1,5 +1,7 @@
import { Cache } from './Cache'
import { Collection } from '../collection/Collection'
import {Awaitable, Maybe} from '../support/types'
import {ErrorWithContext} from '../error/ErrorWithContext'
/**
* Base interface for an item stored in a memory cache.
@@ -44,4 +46,61 @@ export class InMemCache extends Cache {
public async drop(key: string): Promise<void> {
this.items = this.items.whereNot('key', '=', key)
}
public pop(key: string): Awaitable<Maybe<string>> {
const existing = this.items.firstWhere('key', '=', key)
this.items = this.items.where('key', '!=', key)
return existing?.item
}
public async increment(key: string, amount?: number): Promise<number> {
const next = parseInt((await this.fetch(key)) ?? '0', 10) + (amount ?? 1)
await this.put(key, String(next))
return next
}
public async decrement(key: string, amount?: number): Promise<number> {
const next = parseInt((await this.fetch(key)) ?? '0', 10) - (amount ?? 1)
await this.put(key, String(next))
return next
}
public arrayPush(key: string, value: string): Awaitable<void> {
const existing = this.items.where('key', '=', key).first()
const arr = JSON.parse(existing?.item ?? '[]')
if ( !Array.isArray(arr) ) {
throw new ErrorWithContext('Unable to arrayPush: key is not an array', {
key,
value,
})
}
arr.push(value)
if ( existing ) {
existing.item = JSON.stringify(arr)
} else {
this.items.push({
key,
item: JSON.stringify(arr),
})
}
}
public arrayPop(key: string): Awaitable<Maybe<string>> {
const existing = this.items.where('key', '=', key).first()
const arr = JSON.parse(existing?.item ?? '[]')
const value = arr.pop()
if ( existing ) {
existing.item = JSON.stringify(arr)
} else {
this.items.push({
key,
item: JSON.stringify(arr),
})
}
return value
}
}

View File

@@ -0,0 +1,10 @@
import {ErrorWithContext} from './ErrorWithContext'
export class MethodNotSupportedError extends ErrorWithContext {
constructor(
message = 'Method not supported',
context: {[key: string]: any} = {},
) {
super(message, context)
}
}

View File

@@ -1,3 +1,7 @@
import {RequestInfo, RequestInit, Response} from 'node-fetch'
import {unsafeESMImport} from './unsafe'
export const fetch = (url: RequestInfo, init?: RequestInit): Promise<Response> => unsafeESMImport('node-fetch').then(({default: nodeFetch}) => nodeFetch(url, init))
export * from './cache/Cache'
export * from './cache/InMemCache'
@@ -10,6 +14,7 @@ export * from './collection/where'
export * from './const/http'
export * from './error/ErrorWithContext'
export * from './error/MethodNotSupportedError'
export * from './logging/Logger'
export * from './logging/StandardLogger'

View File

@@ -137,7 +137,7 @@ export class BehaviorSubject<T> {
} catch (e) {
if ( e instanceof UnsubscribeError ) {
this.subscribers = this.subscribers.filter(x => x !== subscriber)
} else if (subscriber.error) {
} else if (subscriber.error && e instanceof Error) {
await subscriber.error(e)
} else {
throw e
@@ -181,7 +181,7 @@ export class BehaviorSubject<T> {
try {
await subscriber.complete(finalValue)
} catch (e) {
if ( subscriber.error ) {
if ( subscriber.error && e instanceof Error ) {
await subscriber.error(e)
} else {
throw e

View File

@@ -8,7 +8,7 @@ export type PipeOperator<T, T2> = (subject: T) => T2
/**
* A closure that maps a given pipe item to an item of the same type.
*/
export type ReflexivePipeOperator<T> = (subject: T) => T|void
export type ReflexivePipeOperator<T> = (subject: T) => T
/**
* A condition or condition-resolving function for pipe methods.
@@ -97,7 +97,7 @@ export class Pipe<T> {
*/
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
if ( (typeof check === 'function' && check(this.subject)) || check ) {
Pipe.wrap(op(this.subject))
return Pipe.wrap(op(this.subject))
}
return this
@@ -115,8 +115,7 @@ export class Pipe<T> {
return this
}
Pipe.wrap(op(this.subject))
return this
return Pipe.wrap(op(this.subject))
}
/**
@@ -158,6 +157,8 @@ export type AsyncPipeResolver<T> = () => Awaitable<T>
*/
export type AsyncPipeOperator<T, T2> = (subject: T) => Awaitable<T2>
export type PromisePipeOperator<T, T2> = (subject: T, resolve: (val: T2) => unknown, reject: (err: Error) => unknown) => Awaitable<unknown>
/**
* A closure that maps a given pipe item to an item of the same type.
*/
@@ -193,6 +194,23 @@ export class AsyncPipe<T> {
return new AsyncPipe<T2>(async () => op(await this.subject()))
}
/**
* Apply a transformative operator to the pipe, wrapping it
* in a Promise and passing the resolve/reject callbacks to the
* closure.
* @param op
*/
promise<T2>(op: PromisePipeOperator<T, T2>): AsyncPipe<T2> {
return new AsyncPipe<T2>(() => {
return new Promise<T2>((res, rej) => {
(async () => this.subject())()
.then(subject => {
op(subject, res, rej)
})
})
})
}
/**
* Apply an operator to the pipe, but return the reference
* to the current pipe. The operator is resolved when the

View File

@@ -2,6 +2,7 @@
export function isDebugging(key: string): boolean {
const env = 'EXTOLLO_DEBUG_' + key.split(/(?:\s|\.)+/).join('_')
.toUpperCase()
return process.env[env] === 'yes'
}

View File

@@ -5,6 +5,7 @@ import * as mime from 'mime-types'
import {FileNotFoundError, Filesystem} from './path/Filesystem'
import {Collection} from '../collection/Collection'
import {Readable, Writable} from 'stream'
import {Pipe} from './Pipe'
/**
* An item that could represent a path.
@@ -82,6 +83,8 @@ export class UniversalPath {
protected resourceLocalPath!: string
protected resourceQuery: URLSearchParams = new URLSearchParams()
constructor(
/**
* The path string this path refers to.
@@ -94,6 +97,10 @@ export class UniversalPath {
) {
this.setPrefix()
this.setLocal()
if ( this.isRemote ) {
this.resourceQuery = (new URL(this.toRemote)).searchParams
}
}
/**
@@ -140,6 +147,13 @@ export class UniversalPath {
return new UniversalPath(this.initial)
}
/**
* Get the URLSearchParams for this resource.
*/
get query(): URLSearchParams {
return this.resourceQuery
}
/**
* Get the string of this resource.
*/
@@ -183,7 +197,8 @@ export class UniversalPath {
* Get the fully-prefixed path to this resource.
*/
get toRemote(): string {
return `${this.prefix}${this.resourceLocalPath}`
const query = this.query.toString()
return `${this.prefix}${this.resourceLocalPath}${query ? '?' + query : ''}`
}
/**
@@ -517,4 +532,9 @@ export class UniversalPath {
return false
}
/** Get a new Pipe instance wrapping this. */
toPipe(): Pipe<UniversalPath> {
return Pipe.wrap(this)
}
}

View File

@@ -92,7 +92,7 @@ export class LocalFilesystem extends Filesystem {
isFile: stat.isFile(),
}
} catch (e) {
if ( e?.code === 'ENOENT' ) {
if ( (e as any)?.code === 'ENOENT' ) {
return {
path: new UniversalPath(args.storePath, this),
exists: false,

View File

@@ -52,12 +52,14 @@ export function withTimeout<T>(timeout: number, promise: Promise<T>): TimeoutSub
run: async () => {
let expired = false
let resolved = false
if ( timeout ) {
setTimeout(() => {
expired = true
if ( !resolved ) {
timeoutHandler()
}
}, timeout)
}
const result: T = await promise
resolved = true

24
src/util/unsafe.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* UNSAFE
*
* Sometimes, we need to make a literal `import()` call from within commonJS
* modules in order to pull in ES modules from commonJS.
*
* However, when tsc renders the modules to commonJS, it rewrites _all_ calls
* to `import` as calls to `require`, which means we cannot actually use ES
* modules from commonJS-transpiled TypeScript.
*
* To bypass this, we can eval the literal string. This is a stupid hack and
* I hate it so much, but unfortunately it works.
*
* So, this is a wrapper function that results in a call to the literal
* `import(...)` function in the transpiled code. It should be used VERY
* sparingly.
*
* @see https://github.com/microsoft/TypeScript/issues/43329
* @param path
*/
export function unsafeESMImport(path: string): Promise<any> {
((p: string) => p)(path)
return eval('import(path)') // eslint-disable-line no-eval
}

View File

@@ -41,7 +41,7 @@ export class PugViewEngine extends ViewEngine {
return {
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
debug: this.debug,
compileDebug: this.debug,
// compileDebug: this.debug,
globals: [],
}
}