101 Commits

Author SHA1 Message Date
395e8e4d1c ORM type refactor 2022-04-05 09:38:58 -05:00
f6a7cac05c Improve ORM templates; improve StaticClass typedef; bump version 2022-04-04 14:45:45 -05:00
25265b5560 Only load .env if the file exists; bump version 2022-03-31 15:53:36 -05:00
a779ec1d09 Update content-length calculation to use buffer 2022-03-30 23:59:34 -05:00
bea48602f5 Fix content-length header set 2022-03-30 23:55:41 -05:00
445f16d973 Prevent duplicate bus shutdowns on container destroy; bump version 2022-03-30 23:40:10 -05:00
351a2e14b8 Prevent request Bus instances from creating new IORedis connections 2022-03-30 23:34:17 -05:00
b42e91533a Fix missed return in container.makeNew 2022-03-30 23:20:09 -05:00
78cb26fcb2 Add request container lifecycle handling 2022-03-30 23:04:00 -05:00
514a578260 Make HTTPServer ignore responses that cannot be sent 2022-03-30 22:19:33 -05:00
3d7d583367 Add Request logging 2022-03-30 22:05:46 -05:00
6f66126d38 Improve Response verbose/debug logging 2022-03-30 21:38:36 -05:00
10b3e1ecc3 Temporarily remove timeout logic from HTTPServer 2022-03-30 18:45:34 -05:00
795adac68b Add better detection for write-after-destroy errors on the response 2022-03-30 18:33:37 -05:00
ca348b2ff6 Add Trace logging level; bump version 2022-03-30 18:15:56 -05:00
508d92f759 Add better debugging for Bus connections 2022-03-30 18:02:17 -05:00
a590d78155 Reduce # of duplicate Bus.up() calls; bump version 2022-03-30 17:34:19 -05:00
dbe48ea8a5 Add debugging option to make bus warnings throw Error; bump version 2022-03-30 17:24:45 -05:00
467721f775 Include request parameters in input(...) sources; bump version 2022-03-30 15:35:09 -05:00
153f8f7685 Fix Controller request injection and bump version 2022-03-30 12:13:33 -05:00
ba87ea32c3 Add make helper and bump version 2022-03-29 15:29:27 -05:00
737d06f6f0 Fix preflight middleware ordering and bump version 2022-03-29 15:16:29 -05:00
6ee3e2a729 Fix preflight middleware ordering and bump version 2022-03-29 15:12:51 -05:00
1288e51de0 Disable broadcasting migration events and bump version 2022-03-29 08:46:24 -05:00
1fde692a65 Bump version 2022-03-29 02:31:31 -05:00
cdecb7e628 Fix hanging IORedis connections; add extollo.wontstop debugging helper 2022-03-29 02:30:48 -05:00
8f08b94f74 Error response enhancements, CoreID auth client backend 2022-03-29 01:14:46 -05:00
a039b1ff25 Add Safe value API and start OAuth2Server 2022-02-24 00:00:35 -06:00
70d67c2730 Add model serializer and coreid login provider 2022-02-23 15:15:02 -06:00
0774deea91 bump version
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-27 10:34:19 -06:00
16e5fa00aa Implement queue work and listen commands
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-27 10:34:01 -06:00
e098a5edb7 Version bump
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-26 19:39:00 -06:00
6d1cf18680 Refactor event bus and queue system; detect cycles in DI realization and make
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-26 19:37:54 -06:00
506fb55c74 Start auth provider system
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-20 00:55:21 -06:00
cfd555723b Add whereDefined and mapCall methods to Collection class 2022-01-20 00:55:11 -06:00
32050cb2ce Fix route CLI commands 2022-01-20 00:54:55 -06:00
dc16dfdb81 Make new routing system the default
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-19 13:24:59 -06:00
8cf19792a6 Start routing and pipeline rewrite
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-17 15:57:40 -06:00
9b8333295f Version bump
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-14 14:28:25 -06:00
5ffb91329e Start new validation system and zodified types with excc
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-14 01:04:13 -06:00
b105a61ca2 Add RequestClass to override AppClass
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-16 14:02:15 -06:00
9204a02450 Modify GlobalRegistry to use async local storage to support multiple "global" containers
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-11-27 10:43:26 -06:00
463076d182 Introduce async local storage for request access, more view globals, and improved welcome view
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 10:30:49 -06:00
b5eb407b55 Prevent duplicate auto-discovery for nested packages
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 00:12:51 -06:00
0ed096c782 Add dependency on @extollo/ui and enable recursive discovery
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 00:10:48 -06:00
5175d64e36 Rework authentication system
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-26 14:32:25 -06:00
bd7d6a2dbd Start tests 2021-11-26 14:32:05 -06:00
d251f8bc15 Util: fix Pipe conditionals and add type-safe hasOwnProperty helper 2021-11-26 14:31:47 -06:00
bf4a675faa DI: add logic for static class overrides 2021-11-26 14:31:18 -06:00
6fc901b3ec bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 17:05:22 -06:00
50e0cf3090 Fix prototype access issue with model scopes property
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 17:02:43 -06:00
d245d15ad6 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 16:52:35 -06:00
265837b5cd Fix stupid typescript error...
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 16:50:01 -06:00
fe0b4d6d8f bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-11-25 16:39:53 -06:00
ce1d22ff44 Make the linter happy
Some checks failed
continuous-integration/drone/push Build is failing
2021-11-25 16:39:25 -06:00
b7bfb3e153 Model: fix eager-loaded relation loading from static query 2021-11-25 16:39:17 -06:00
e57819d318 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-15 14:00:29 -06:00
0a9dd30909 Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-11 16:42:37 -06:00
d92c8b5409 Start implementation of model relations
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-10 21:30:59 -06:00
589cb7d579 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 22:29:34 -05:00
3680ad1914 Fix back-fill on insert for Model.save 2021-10-19 22:29:15 -05:00
96e13d85fc Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:59:14 -05:00
5a9283ad85 orm(PostgreSQLDialect): double-quote column names in INSERT field lists 2021-10-19 13:59:00 -05:00
b1ea489ccb Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:00:27 -05:00
c3f2779650 Add generic to APIResponse 2021-10-19 13:00:14 -05:00
248b24e612 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 10:26:47 -05:00
b4a9057e2b CLI invocation output better debugging infor 2021-10-19 10:26:32 -05:00
c078d695a8 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-18 17:23:30 -05:00
55ffadc742 Export CLI decorators
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 17:23:16 -05:00
56574d43ce Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:57:44 -05:00
e16f02ce12 Readd migrations
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 14:49:19 -05:00
c34fad3502 Fix path in drone docs deploy
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:02:00 -05:00
156006053b Fix path in drone static deploy
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
2021-10-18 13:53:37 -05:00
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
26e0444e40 version 0.5.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-25 09:15:41 -05:00
fcce28081b AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-25 09:15:01 -05:00
e86cf420df Named routes & basic login framework 2021-07-17 12:49:07 -05:00
e33d8dee8f Add support for registering vendor asset routes
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-07 22:50:48 -05:00
39d97d6e14 version 0.4.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-07 20:15:36 -05:00
f496046461 File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing
- Clean up UniversalPath implementation
    - Use Readable/Writable types correctly for stream methods
    - Add .list() methods for getting child files

- Make Response body specify explicit types and support
  writing Readable streams to the body

- Create a static file server that supports directory listing
2021-07-07 20:13:23 -05:00
b3b5b169e8 Add mechanism for NPM package auto-discovery
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-02 21:45:15 -05:00
5d960e6186 chore: make Rehydratable use Awaitable; add docblock 2021-07-02 21:44:34 -05:00
cf6d14abca - Start support for auto-generated routes using UniversalPath
All checks were successful
continuous-integration/drone/push Build is passing
- Start support for custom view engine props & functions
- Start login template and namespace
2021-06-29 01:44:07 -05:00
faa8a31102 Route - prevent pre/post middleware from being applied twice
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-29 00:34:05 -05:00
7506d6567d Support registering namespaced view directories; add lib() universal path
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-24 00:14:04 -05:00
a69c81ed35 chore(version): 0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-17 19:35:50 -05:00
36b451c32b Expose auth repos in context; create routes commands
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-17 19:35:31 -05:00
9796a7277e Begin abstracting global container into injector 2021-06-17 19:34:32 -05:00
f00233d49a Add middleware and logic for bootstrapping the session auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 13:24:12 -05:00
91abcdf8ef Start auth framework
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 12:02:36 -05:00
c264d45927 Add query executed event; forward model events to global event bus
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 08:36:35 -05:00
61731c4ebd Add basic concepts for event bus, and implement in request and model
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-04 01:03:31 -05:00
dab3d006c8 Containers - add ability to purge/release factories; override factories in scoped 2021-06-04 01:03:10 -05:00
cd9bec7c5e Remove old doc build trigger from CI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-06-02 22:45:49 -05:00
263 changed files with 14749 additions and 3013 deletions

View File

@@ -22,7 +22,7 @@ steps:
from_secret: docs_deploy_key
port: 22
source: extollo_api_documentation.tar.gz
target: /var/nfs/general/static/sites/extollo
target: /var/nfs/storage/static/sites/extollo
when:
event: promote
target: docs
@@ -38,7 +38,7 @@ steps:
from_secret: docs_deploy_key
port: 22
script:
- cd /var/nfs/general/static/sites/extollo
- cd /var/nfs/storage/static/sites/extollo
- rm -rf docs
- tar xzf extollo_api_documentation.tar.gz
- rm -rf extollo_api_documentation.tar.gz
@@ -103,10 +103,19 @@ steps:
event:
exclude: tag
- name: build module
- name: Install dependencies
image: glmdev/node-pnpm:latest
commands:
- pnpm i
- name: Lint code
image: glmdev/node-pnpm:latest
commands:
- pnpm lint
- name: build module
image: glmdev/node-pnpm:latest
commands:
- pnpm build
- mkdir artifacts
- tar czf artifacts/extollo-lib.tar.gz lib
@@ -217,18 +226,3 @@ steps:
when:
status: failure
event: pull_request
- name: trigger documentation build
image: plugins/downstream
settings:
server: https://ci.garrettmills.dev
token:
from_secret: drone_token
fork: false
last_successful: true
deploy: production
repositories:
- Extollo/docs@master
when:
status: success
event: tag

55
.idea/codeStyles/Project.xml generated Normal file
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.3.0",
"version": "0.9.23",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -8,21 +8,34 @@
"lib": "lib"
},
"dependencies": {
"@atao60/fse-cli": "^0.1.6",
"@extollo/ui": "^0.1.0",
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/ioredis": "^4.26.6",
"@types/jsonwebtoken": "^8.5.8",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1",
"@types/node": "^14.14.37",
"@types/node": "^14.17.4",
"@types/pg": "^8.6.0",
"@types/pluralize": "^0.0.29",
"@types/pug": "^2.0.4",
"@types/rimraf": "^3.0.0",
"@types/ssh2": "^0.5.46",
"@types/uuid": "^8.3.0",
"bcrypt": "^5.0.1",
"busboy": "^0.3.1",
"cli-table": "^0.3.6",
"colors": "^1.4.0",
"dotenv": "^8.2.0",
"ioredis": "^4.27.6",
"jsonwebtoken": "^8.5.1",
"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",
@@ -34,12 +47,12 @@
"typedoc-plugin-pages-fork": "^0.0.1",
"typedoc-plugin-sourcefile-url": "^1.0.6",
"typescript": "^4.2.3",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"zod": "^3.11.6"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint",
"build": "tsc",
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
"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",
@@ -58,8 +71,25 @@
"author": "garrettmills <shout@garrettmills.dev>",
"license": "MIT",
"devDependencies": {
"@types/chai": "^4.2.22",
"@types/mocha": "^9.0.0",
"@types/sinon": "^10.0.6",
"@types/wtfnode": "^0.7.0",
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"eslint": "^7.27.0"
"chai": "^4.3.4",
"eslint": "^7.27.0",
"mocha": "^9.1.3",
"sinon": "^12.0.1",
"wtfnode": "^0.9.1"
},
"extollo": {
"discover": true,
"units": {
"discover": false
},
"recursiveDependencies": {
"discover": true
}
}
}

3170
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

@@ -0,0 +1,77 @@
import {Unit} from '../lifecycle/Unit'
import {Injectable, Inject, StaticInstantiable} from '../di'
import {Logging} from '../service/Logging'
import {Middlewares} from '../service/Middlewares'
import {CanonicalResolver} from '../service/Canonical'
import {Middleware} from '../http/routing/Middleware'
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {ViewEngine} from '../views/ViewEngine'
import {SecurityContext} from './context/SecurityContext'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Config} from '../service/Config'
import {ErrorWithContext, hasOwnProperty} from '../util'
import {Route} from '../http/routing/Route'
@Injectable()
export class Authentication extends Unit {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly middleware!: Middlewares
@Inject()
protected readonly config!: Config
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
async up(): Promise<void> {
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
this.container().onResolve<ViewEngine>(ViewEngine)
.then((engine: ViewEngine) => {
engine.registerGlobalFactory('user', req => {
return () => req?.make<SecurityContext>(SecurityContext)?.getUser()
})
})
const config = this.config.get('auth.providers', {})
const middleware = this.config.get('auth.middleware', SessionAuthMiddleware)
if ( !(middleware?.prototype instanceof Middleware) ) {
throw new ErrorWithContext('Auth middleware must extend Middleware base class', {
providedValue: middleware,
configKey: 'auth.middleware',
})
}
for ( const name in config ) {
if ( !hasOwnProperty(config, name) ) {
continue
}
if ( this.providers[name] ) {
this.logging.warn(`Registering duplicate authentication provider: ${name}`)
}
this.logging.verbose(`Registered authentication provider: ${name}`)
this.providers[name] = this.make(config[name].driver, name, config[name].config)
Route.group(`/auth/${name}`, () => {
this.providers[name].routes()
}).pre(middleware)
}
}
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {
return (key: string) => {
return ({
required: AuthRequiredMiddleware,
guest: GuestRequiredMiddleware,
web: SessionAuthMiddleware,
})[key]
}
}
}

View File

@@ -0,0 +1,11 @@
import {HTTPError} from '../http/HTTPError'
import {HTTPStatus} from '../util'
/**
* Error thrown when a user attempts an action that they are not authorized to perform.
*/
export class NotAuthorizedError extends HTTPError {
constructor(message = 'Not Authorized') {
super(HTTPStatus.FORBIDDEN, message)
}
}

51
src/auth/config.ts Normal file
View File

@@ -0,0 +1,51 @@
import {Instantiable, isInstantiable} from '../di'
import {AuthenticatableRepository} from './types'
import {hasOwnProperty} from '../util'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Middleware} from '../http/routing/Middleware'
export interface AuthenticationConfig {
storage: Instantiable<AuthenticatableRepository>,
middleware?: Instantiable<Middleware>,
providers?: {
[key: string]: {
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
config: LoginProviderConfig,
},
},
}
export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig {
if ( typeof what !== 'object' || !what ) {
return false
}
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
return false
}
if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) {
return false
}
if ( typeof what.providers !== 'object' ) {
return false
}
for ( const key in what.providers ) {
if ( !hasOwnProperty(what.providers, key) ) {
continue
}
const source = what.providers[key]
if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) {
return false
}
if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) {
return false
}
}
return true
}

View File

@@ -0,0 +1,119 @@
import {Inject, Injectable} from '../../di'
import {Awaitable, HTTPStatus, Maybe} from '../../util'
import {Authenticatable, AuthenticatableRepository} from '../types'
import {Logging} from '../../service/Logging'
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
import {UserFlushedEvent} from '../event/UserFlushedEvent'
import {Bus} from '../../support/bus'
import {HTTPError} from '../../http/HTTPError'
/**
* Base-class for a context that authenticates users and manages security.
*/
@Injectable()
export abstract class SecurityContext {
@Inject()
protected readonly bus!: Bus
@Inject()
protected readonly logging!: Logging
/** The currently authenticated user, if one exists. */
protected authenticatedUser?: Authenticatable
constructor(
/** The repository where users are persisted. */
public readonly repository: AuthenticatableRepository,
/** The name of this context. */
public readonly name: string,
) { }
/**
* Called when the context is created. Can be used by child-classes to do setup work.
*/
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Authenticate the given user, without persisting the authentication.
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
* @param user
*/
async authenticateOnce(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
* Authenticate the given user and persist the authentication.
* @param user
*/
async authenticate(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.persist()
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
* Unauthenticate the current user, if one exists, but do not persist the change.
*/
async flushOnce(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.bus.push(new UserFlushedEvent(user, this))
}
}
/**
* Unauthenticate the current user, if one exists, and persist the change.
*/
async flush(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.persist()
await this.bus.push(new UserFlushedEvent(user, this))
}
}
/**
* Assuming a user is still authenticated in the context,
* try to look up and fill in the user.
*/
abstract resume(): Awaitable<void>
/**
* Write the current state of the security context to whatever storage
* medium the context's host provides.
*/
abstract persist(): Awaitable<void>
/**
* Get the currently authenticated user, if one exists.
*/
getUser(): Maybe<Authenticatable> {
return this.authenticatedUser
}
/** Get the current user or throw an authorization error. */
user(): Authenticatable {
if ( !this.hasUser() ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
const user = this.getUser()
if ( !user ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
return user
}
/**
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
return Boolean(this.authenticatedUser)
}
}

View File

@@ -0,0 +1,39 @@
import {SecurityContext} from './SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableRepository} from '../types'
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
export const EXTOLLO_AUTH_SESSION_KEY = '@extollo:auth.securityIdentifier'
/**
* Security context implementation that uses the session as storage.
*/
@Injectable()
export class SessionSecurityContext extends SecurityContext {
@Inject()
protected readonly session!: Session
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'session')
}
persist(): Awaitable<void> {
this.session.set(EXTOLLO_AUTH_SESSION_KEY, this.getUser()?.getIdentifier())
}
async resume(): Promise<void> {
const identifier = this.session.get(EXTOLLO_AUTH_SESSION_KEY)
if ( identifier ) {
const user = await this.repository.getByIdentifier(identifier)
if ( user ) {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
}
}
}
}

View File

@@ -0,0 +1,12 @@
import {SecurityContext} from '../context/SecurityContext'
import {Authenticatable} from '../types'
import {BaseEvent} from '../../support/bus'
export abstract class AuthenticationEvent extends BaseEvent {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
}

View File

@@ -0,0 +1,8 @@
import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event fired when a user is authenticated.
*/
export class UserAuthenticatedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
}

View File

@@ -0,0 +1,8 @@
import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event raised when a user is re-authenticated to a security context
*/
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
}

View File

@@ -0,0 +1,8 @@
import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event fired when a user is unauthenticated.
*/
export class UserFlushedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserFlushedEvent'
}

42
src/auth/index.ts Normal file
View File

@@ -0,0 +1,42 @@
export * from './types'
export * from './AuthenticatableAlreadyExistsError'
export * from './NotAuthorizedError'
export * from './Authentication'
export * from './context/SecurityContext'
export * from './context/SessionSecurityContext'
export * from './event/AuthenticationEvent'
export * from './event/UserAuthenticatedEvent'
export * from './event/UserAuthenticationResumedEvent'
export * from './event/UserFlushedEvent'
export * from './middleware/AuthRequiredMiddleware'
export * from './middleware/GuestRequiredMiddleware'
export * from './middleware/SessionAuthMiddleware'
export * from './provider/basic/BasicLoginAttempt'
export * from './provider/basic/BasicLoginProvider'
export * from './provider/basic/BasicRegistrationAttempt'
export * from './provider/oauth/OAuth2LoginProvider'
export * from './provider/oauth/CoreIDLoginProvider'
export * from './serial/AuthenticationEventSerializer'
export * from './repository/orm/ORMUser'
export * from './repository/orm/ORMUserRepository'
export * from './config'
export * from './server/types'
export * from './server/models/OAuth2TokenModel'
export * from './server/repositories/ConfigClientRepository'
export * from './server/repositories/ConfigScopeRepository'
export * from './server/repositories/ClientRepositoryFactory'
export * from './server/repositories/ScopeRepositoryFactory'
export * from './server/repositories/ORMTokenRepository'
export * from './server/repositories/TokenRepositoryFactory'
export * from './server/repositories/CacheRedemptionCodeRepository'
export * from './server/repositories/RedemptionCodeRepositoryFactory'
export * from './server/OAuth2Server'

View File

@@ -0,0 +1,36 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {Routing} from '../../service/Routing'
import {Session} from '../../http/session/Session'
// TODO handle JSON and non-web
@Injectable()
export class AuthRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) {
this.session.set('@extollo:auth.intention', this.request.url)
if ( this.routing.hasNamedRoute('@auth:login') ) {
return redirect(this.routing.getNamedPath('@auth:login').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

View File

@@ -0,0 +1,30 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {Routing} from '../../service/Routing'
import {redirect} from '../../http/response/RedirectResponseFactory'
// TODO handle JSON and non-web
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

View File

@@ -0,0 +1,44 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable, Instantiable} from '../../di'
import {Config} from '../../service/Config'
import {Logging} from '../../service/Logging'
import {AuthenticatableRepository} from '../types'
import {Maybe} from '../../util'
import {AuthenticationConfig, isAuthenticationConfig} from '../config'
import {ResponseObject} from '../../http/routing/Route'
import {SessionSecurityContext} from '../context/SessionSecurityContext'
import {SecurityContext} from '../context/SecurityContext'
/**
* Injects a SessionSecurityContext into the request and attempts to
* resume the user's authentication.
*/
@Injectable()
export class SessionAuthMiddleware extends Middleware {
@Inject()
protected readonly config!: Config
@Inject()
protected readonly logging!: Logging
async apply(): Promise<ResponseObject> {
this.logging.debug('Applying session auth middleware.')
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
this.request.registerSingletonInstance(SecurityContext, context)
await context.resume()
}
/**
* Build the correct AuthenticatableRepository based on the auth config.
* @protected
*/
protected getRepository(): AuthenticatableRepository {
const config: Maybe<AuthenticationConfig> = this.config.get('auth')
if ( !isAuthenticationConfig(config) ) {
throw new TypeError('Invalid authentication config.')
}
const repo: Instantiable<AuthenticatableRepository> = config.storage
return this.make(repo)
}
}

View File

@@ -0,0 +1,74 @@
import {Request} from '../../http/lifecycle/Request'
import {ResponseObject, Route} from '../../http/routing/Route'
import {GuestRequiredMiddleware} from '../middleware/GuestRequiredMiddleware'
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
import {Session} from '../../http/session/Session'
export interface LoginProviderConfig {
default: boolean,
allow?: {
login?: boolean,
registration?: boolean,
},
}
@Injectable()
export abstract class LoginProvider<TConfig extends LoginProviderConfig> {
@Inject()
protected readonly request!: RequestLocalStorage
protected get security(): SecurityContext {
return this.request.get().make(SecurityContext)
}
constructor(
protected name: string,
protected config: TConfig,
) {}
public routes(): void {
Route.get('login')
.alias(`@auth:${this.name}:login`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:login')))
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.login.bind(this))
Route.any('logout')
.alias(`@auth:${this.name}:logout`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:logout')))
.pre(AuthRequiredMiddleware)
.passingRequest()
.handledBy(this.logout.bind(this))
Route.get('register')
.alias(`@auth:${this.name}:register`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:register')))
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.registration.bind(this))
}
public abstract login(request: Request): ResponseObject
public abstract logout(request: Request): ResponseObject
public registration(request: Request): ResponseObject {
return this.login(request)
}
protected redirectToIntendedRoute(): ResponseObject {
const intent = this.request
.get()
.make<Session>(Session)
.safe('@extollo:auth.intention')
.or('/')
.string()
return redirect(intent)
}
}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod'
export type BasicLoginAttempt = z.infer<typeof BasicLoginAttemptType>
export const BasicLoginAttemptType = z.object({
username: z.string().nonempty(),
password: z.string().nonempty(),
})

View File

@@ -0,0 +1,75 @@
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {view} from '../../../http/response/ViewResponseFactory'
import {Valid, Validator} from '../../../validation/Validator'
import {BasicLoginAttempt, BasicLoginAttemptType} from './BasicLoginAttempt'
import {BasicRegistrationAttempt, BasicRegistrationAttemptType} from './BasicRegistrationAttempt'
/**
* LoginProvider implementation that provides basic username/password login.
*/
export class BasicLoginProvider extends LoginProvider<LoginProviderConfig> {
public routes(): void {
super.routes()
Route.post('/login')
.alias(`@auth:${this.name}:login.submit`)
.input(Validator.fromSchema<BasicLoginAttempt>(BasicLoginAttemptType))
.handledBy((...p) => this.attemptLogin(...p))
Route.post('/register')
.alias(`@auth:${this.name}:register.submit`)
.input(Validator.fromSchema<BasicRegistrationAttempt>(BasicRegistrationAttemptType))
.handledBy((...p) => this.attemptRegistration(...p))
}
public login(): ResponseObject {
return view('@extollo:auth:login')
}
public async logout(): Promise<ResponseObject> {
await this.security.flush()
return view('@extollo:auth:logout')
}
public registration(): ResponseObject {
return view('@extollo:auth:register')
}
/** Attempt to authenticate the user with a username/password. */
public async attemptLogin(attempt: Valid<BasicLoginAttempt>): Promise<ResponseObject> {
const user = await this.security.repository.getByIdentifier(attempt.username)
if ( !user ) {
throw new Error('TODO')
}
if ( !(await user.validateCredential(attempt.password)) ) {
throw new Error('TODO')
}
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
/** Attempt to register the user with a username/password. */
public async attemptRegistration(attempt: Valid<BasicRegistrationAttempt>): Promise<ResponseObject> {
const existingUser = await this.security.repository.getByIdentifier(attempt.username)
if ( existingUser ) {
throw new Error('TODO')
}
if ( attempt.password !== attempt.passwordConfirmation ) {
throw new Error('TODO')
}
const user = await this.security.repository.createFromCredentials(attempt.username, attempt.password)
;(user as any).firstName = attempt.firstName
;(user as any).lastName = attempt.lastName
if ( typeof (user as any).save === 'function' ) {
await (user as any).save()
}
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
}

View File

@@ -0,0 +1,19 @@
import { z } from 'zod'
export type BasicRegistrationAttempt = z.infer<typeof BasicRegistrationAttemptType>
export const BasicRegistrationAttemptType = z.object({
firstName: z.string().nonempty(),
lastName: z.string().nonempty(),
username: z.string().nonempty(),
password: z.string()
.nonempty()
.min(8),
passwordConfirmation: z.string()
.nonempty()
.min(8),
})

View File

@@ -0,0 +1,101 @@
/* eslint camelcase: 0 */
import {OAuth2LoginProvider, OAuth2LoginProviderConfig} from './OAuth2LoginProvider'
import {Authenticatable} from '../../types'
import {Request} from '../../../http/lifecycle/Request'
import {ErrorWithContext, uuid4, fetch} from '../../../util'
/**
* OAuth2LoginProvider implementation that authenticates users against a
* Starship CoreID server.
*/
export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProviderConfig> {
protected async callback(request: Request): Promise<Authenticatable> {
// Get authentication_code from the request
const code = String(request.input('code') || '')
if ( !code ) {
throw new ErrorWithContext('Unable to authenticate user: missing login code', {
input: request.input(),
})
}
// Get OAuth2 token from CoreID
const token = await this.getToken(code)
// Get user from endpoint
const userData = await this.getUserData(token)
// Return authenticatable instance
const existing = await this.security.repository.getByIdentifier(userData.uid)
if ( existing ) {
this.updateUser(existing, userData)
return existing
}
const user = await this.security.repository.createFromCredentials(userData.uid, uuid4())
this.updateUser(user, userData)
return user
}
/** Given an access token, look up the associated user's information. */
protected async getUserData(token: string): Promise<any> {
const userResponse = await fetch(
this.config.userUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
)
const userData: any = await userResponse.json()
if ( !userData?.data?.uid ) {
throw new ErrorWithContext('Unable to extract user from response', {
userData,
})
}
return userData.data
}
/** Given a login code, redeem it for an access token. */
protected async getToken(code: string): Promise<string> {
const body: string[] = [
'code=' + encodeURIComponent(code),
'client_id=' + encodeURIComponent(this.config.clientId),
'client_secret=' + encodeURIComponent(this.config.clientSecret),
'grant_type=authorization_code',
]
const response = await fetch(
this.config.tokenUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.join('&'),
},
)
const data = await response.json()
const token = (data as any).access_token
if ( !token ) {
throw new ErrorWithContext('Unable to obtain access token from response', {
data,
})
}
return String(token)
}
/** Update values on the Authenticatable from user data. */
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
protected updateUser(user: any, data: any): void {
user.firstName = data.first_name
user.lastName = data.last_name
user.email = data.email
user.tagline = data.tagline
user.photoUrl = data.profile_photo
}
}

View File

@@ -0,0 +1,98 @@
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {Inject, Injectable} from '../../../di'
import {Routing} from '../../../service/Routing'
import {GuestRequiredMiddleware} from '../../middleware/GuestRequiredMiddleware'
import {redirect} from '../../../http/response/RedirectResponseFactory'
import {view} from '../../../http/response/ViewResponseFactory'
import {Request} from '../../../http/lifecycle/Request'
import {Awaitable} from '../../../util'
import {Authenticatable} from '../../types'
export interface OAuth2LoginProviderConfig extends LoginProviderConfig {
displayName: string,
clientId: string|number
clientSecret: string
loginUrl: string
loginMessage?: string
logoutUrl?: string
tokenUrl: string,
userUrl: string,
}
/**
* LoginProvider implementation for OAuth2-based logins.
*/
@Injectable()
export abstract class OAuth2LoginProvider<TConfig extends OAuth2LoginProviderConfig> extends LoginProvider<TConfig> {
@Inject()
protected readonly routing!: Routing
public routes(): void {
super.routes()
Route.any('redirect')
.alias(`@auth:${this.name}:redirect`)
.pre(GuestRequiredMiddleware)
.handledBy(() => redirect(this.getLoginUrl()))
Route.any('callback')
.alias(`@auth:${this.name}:callback`)
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.handleCallback.bind(this))
}
protected async handleCallback(request: Request): Promise<ResponseObject> {
const user = await this.callback(request)
if ( user ) {
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
return redirect(this.routing.getNamedPath(`@auth:${this.name}:login`).toRemote)
}
/**
* After redirecting back from the OAuth2 server, look up the user information.
* @param request
* @protected
*/
protected abstract callback(request: Request): Awaitable<Authenticatable>
public login(): ResponseObject {
const buttonUrl = this.routing
.getNamedPath(`@auth:${this.name}:redirect`)
.toRemote
return view('@extollo:auth:message', {
message: this.config.loginMessage ?? `Sign-in with ${this.config.displayName} to continue`,
buttonText: 'Sign-in',
buttonUrl,
})
}
public async logout(): Promise<ResponseObject> {
await this.security.flush()
if ( this.config.logoutUrl ) {
return redirect(this.config.logoutUrl)
}
return view('@extollo:auth:message', {
message: 'You have been signed-out',
})
}
/**
* Get the URL where the user should be redirected to sign-in.
* @protected
*/
protected getLoginUrl(): string {
const callbackRoute = this.routing.getNamedPath(`@auth:${this.name}:callback`)
return this.config.loginUrl
.replace(/%c/g, String(this.config.clientId))
.replace(/%r/g, callbackRoute.toRemote)
}
}

View File

@@ -0,0 +1,77 @@
import * as bcrypt from 'bcrypt'
import {Field, FieldType, Model} from '../../../orm'
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
import {Injectable} from '../../../di'
import {Awaitable, JSONState} from '../../../util'
/**
* A basic ORM-driven user class.
*/
@Injectable()
export class ORMUser extends Model implements Authenticatable {
protected static table = 'users'
protected static key = 'user_id'
/** The primary key of the user in the table. */
@Field(FieldType.serial, 'user_id')
public userId!: number
/** The unique string-identifier of the user. */
@Field(FieldType.varchar)
public username!: string
/** The user's first name. */
@Field(FieldType.varchar, 'first_name')
public firstName?: string
/** The user's last name. */
@Field(FieldType.varchar, 'last_name')
public lastName?: string
/** The hashed and salted password of the user. */
@Field(FieldType.varchar, 'password_hash')
public passwordHash!: string
/** Human-readable display name of the user. */
getDisplay(): string {
if ( this.firstName || this.lastName ) {
return `${this.firstName} ${this.lastName}`
}
return this.username
}
/** Globally-unique identifier of the user. */
getUniqueIdentifier(): AuthenticatableIdentifier {
return `user-${this.userId}`
}
/** Unique identifier of the user. */
getIdentifier(): AuthenticatableIdentifier {
return this.username
}
/** Check if the provided password is valid for the user. */
verifyPassword(password: string): Awaitable<boolean> {
return bcrypt.compare(password, this.passwordHash)
}
/** Change the user's password, hashing it. */
async setPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, 10)
}
validateCredential(credential: string): Awaitable<boolean> {
return this.verifyPassword(credential)
}
async dehydrate(): Promise<JSONState> {
return this.toQueryRow()
}
async rehydrate(state: JSONState): Promise<void> {
await this.assumeFromSource(state)
}
}

View File

@@ -0,0 +1,51 @@
import {
Authenticatable,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../../types'
import {Awaitable, Maybe, uuid4} from '../../../util'
import {ORMUser} from './ORMUser'
import {Container, Inject, Injectable} from '../../../di'
import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
@Inject('injector')
protected readonly injector!: Container
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query()
.where('username', '=', id)
.first()
}
/** Returns true if this repository supports registering users. */
supportsRegistration(): boolean {
return true
}
/** Create a user in this repository from basic credentials. */
async createFromCredentials(username: string, password: string): Promise<Authenticatable> {
if ( await this.getByIdentifier(username) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
username,
})
}
const user = <ORMUser> this.injector.makeByStaticOverride(ORMUser)
user.username = username
await user.setPassword(password)
await user.save()
return user
}
/** Create a user in this repository from an external Authenticatable instance. */
async createFromExternal(user: Authenticatable): Promise<Authenticatable> {
return this.createFromCredentials(String(user.getUniqueIdentifier()), uuid4())
}
}

View File

@@ -0,0 +1,54 @@
import {BaseSerializer, ObjectSerializer, SerialPayload} from '../../support/bus'
import {AuthenticationEvent} from '../event/AuthenticationEvent'
import {ErrorWithContext, JSONState} from '../../util'
import {Authenticatable} from '../types'
import {StaticInstantiable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
import {UserFlushedEvent} from '../event/UserFlushedEvent'
export interface AuthenticationEventSerialPayload extends JSONState {
user: SerialPayload<Authenticatable, JSONState>
eventName: string
}
@ObjectSerializer()
export class AuthenticationEventSerializer extends BaseSerializer<AuthenticationEvent, AuthenticationEventSerialPayload> {
protected async decodeSerial(serial: AuthenticationEventSerialPayload): Promise<AuthenticationEvent> {
const user = await this.getSerialization().decode(serial.user)
const context = await this.getRequest().make(SecurityContext)
const EventClass = this.getEventClass(serial.eventName)
return new EventClass(user, context)
}
protected async encodeActual(actual: AuthenticationEvent): Promise<AuthenticationEventSerialPayload> {
return {
eventName: actual.eventName,
user: await this.getSerialization().encode(actual.user),
}
}
protected getName(): string {
return '@extollo/lib:AuthenticationEventSerializer'
}
matchActual(some: AuthenticationEvent): boolean {
return some instanceof AuthenticationEvent
}
protected getEventClass(name: string): StaticInstantiable<AuthenticationEvent> {
if ( name === '@extollo/lib:UserAuthenticatedEvent' ) {
return UserAuthenticatedEvent
} else if ( name === '@extollo/lib:UserAuthenticationResumedEvent' ) {
return UserAuthenticationResumedEvent
} else if ( name === '@extollo/lib:UserFlushedEvent' ) {
return UserFlushedEvent
}
throw new ErrorWithContext('Unable to map event name to AuthenticationEvent implementation', {
eventName: name,
})
}
}

View File

@@ -0,0 +1,158 @@
import {Controller} from '../../http/Controller'
import {Injectable} from '../../di'
import {ResponseObject, Route} from '../../http/routing/Route'
import {Request} from '../../http/lifecycle/Request'
import {Session} from '../../http/session/Session'
import {
ClientRepository,
OAuth2Client,
OAuth2FlowType,
OAuth2Scope,
RedemptionCodeRepository,
ScopeRepository,
} from './types'
import {HTTPError} from '../../http/HTTPError'
import {HTTPStatus, Maybe} from '../../util'
import {view} from '../../http/response/ViewResponseFactory'
import {SecurityContext} from '../context/SecurityContext'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
@Injectable()
export class OAuth2Server extends Controller {
public static routes(): void {
Route.get('/oauth2/authorize')
.alias('@oauth2:authorize')
.pre(AuthRequiredMiddleware)
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization)
Route.post('/oauth2/authorize')
.alias('@oauth2:authorize:submit')
.pre(AuthRequiredMiddleware)
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.authorizeAndRedirect)
Route.post('/oauth2/redeem')
.alias('@oauth2:authorize:redeem')
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.redeemToken)
}
async redeemToken(request: Request): Promise<ResponseObject> {
const authParts = String(request.getHeader('Authorization')).split(':')
if ( authParts.length !== 2 ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
const clientRepo = <ClientRepository> request.make(ClientRepository)
const [clientId, clientSecret] = authParts
const client = await clientRepo.find(clientId)
if ( !client || client.secret !== clientSecret ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
const codeRepo = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
const codeString = request.safe('code').string()
const code = await codeRepo.find(codeString)
if ( !code ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
}
async authorizeAndRedirect(request: Request): Promise<ResponseObject> {
// Look up the client in the client repo
const session = <Session> request.make(Session)
const clientId = session.safe('oauth2.authorize.clientId').string()
const client = await this.getClient(request, clientId)
const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows)
if ( flowType === OAuth2FlowType.code ) {
return this.authorizeCodeFlow(request, client)
}
}
protected async authorizeCodeFlow(request: Request, client: OAuth2Client): Promise<ResponseObject> {
const session = <Session> request.make(Session)
const security = <SecurityContext> request.make(SecurityContext)
const codeRepository = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
const user = security.user()
const scope = session.get('oauth2.authorize.scope')
const redirectUri = session.safe('oauth2.authorize.redirectUri').in(client.allowedRedirectUris)
// FIXME store authorization
const code = await codeRepository.issue(user, client, scope)
const uri = new URL(redirectUri)
uri.searchParams.set('code', code.code)
return redirect(uri)
}
async promptForAuthorization(request: Request): Promise<ResponseObject> {
// Look up the client in the client repo
const clientId = request.safe('client_id').string()
const client = await this.getClient(request, clientId)
// Make sure the requested flow type is valid for this client
const session = <Session> request.make(Session)
const flowType = request.safe('response_type').in(client.allowedFlows)
const redirectUri = request.safe('redirect_uri').in(client.allowedRedirectUris)
session.set('oauth2.authorize.clientId', client.id)
session.set('oauth2.authorize.flow', flowType)
session.set('oauth2.authorize.redirectUri', redirectUri)
// Set the state if necessary
const state = request.input('state') || ''
if ( state ) {
session.set('oauth2.authorize.state', String(state))
} else {
session.forget('oauth2.authorize.state')
}
// If the request specified a scope, validate it and set it in the session
const scope = await this.getScope(request, client)
// Show a view prompting the user to approve the access
return view('@extollo:oauth2:authorize', {
clientName: client.display,
scopeDescription: scope?.description,
redirectDomain: (new URL(redirectUri)).host,
})
}
protected async getClient(request: Request, clientId: string): Promise<OAuth2Client> {
const clientRepo = <ClientRepository> request.make(ClientRepository)
const client = await clientRepo.find(clientId)
if ( !client ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', {
clientId,
})
}
return client
}
protected async getScope(request: Request, client: OAuth2Client): Promise<Maybe<OAuth2Scope>> {
const session = <Session> request.make(Session)
const scopeName = String(request.input('scope') || '')
let scope: Maybe<OAuth2Scope> = undefined
if ( scopeName ) {
const scopeRepo = <ScopeRepository> request.make(ScopeRepository)
scope = await scopeRepo.findByName(scopeName)
if ( !scope || !client.allowedScopeIds.includes(scope.id) ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid scope', {
scopeName,
})
}
session.set('oauth2.authorize.scope', scope.id)
} else {
session.forget('oauth2.authorize.state')
}
return scope
}
}

View File

@@ -0,0 +1,30 @@
import {Field, FieldType, Model} from '../../../orm'
import {OAuth2Token} from '../types'
export class OAuth2TokenModel extends Model implements OAuth2Token {
public static table = 'oauth2_tokens'
public static key = 'oauth2_token_id'
@Field(FieldType.serial, 'oauth2_token_id')
protected oauth2TokenId!: number
public get id(): string {
return String(this.oauth2TokenId)
}
@Field(FieldType.varchar, 'user_id')
public userId!: string
@Field(FieldType.varchar, 'client_id')
public clientId!: string
@Field(FieldType.timestamp)
public issued!: Date
@Field(FieldType.timestamp)
public expires!: Date
@Field(FieldType.varchar)
public scope?: string
}

View File

@@ -0,0 +1,33 @@
import {isOAuth2RedemptionCode, OAuth2Client, OAuth2RedemptionCode, RedemptionCodeRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {Cache, Maybe, uuid4} from '../../../util'
import {Authenticatable} from '../../types'
@Injectable()
export class CacheRedemptionCodeRepository extends RedemptionCodeRepository {
@Inject()
protected readonly cache!: Cache
async find(codeString: string): Promise<Maybe<OAuth2RedemptionCode>> {
const cacheKey = `@extollo:oauth2:redemption:${codeString}`
if ( await this.cache.has(cacheKey) ) {
const code = await this.cache.safe(cacheKey).then(x => x.json())
if ( isOAuth2RedemptionCode(code) ) {
return code
}
}
}
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2RedemptionCode> {
const code = {
scope,
clientId: client.id,
userId: user.getUniqueIdentifier(),
code: uuid4(),
}
const cacheKey = `@extollo:oauth2:redemption:${code.code}`
await this.cache.put(cacheKey, JSON.stringify(code))
return code
}
}

View File

@@ -0,0 +1,74 @@
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 {ConfigClientRepository} from './ConfigClientRepository'
/**
* A dependency injection factory that matches the abstract ClientRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class ClientRepositoryFactory extends AbstractFactory<ClientRepository> {
protected get config(): Config {
return Container.getContainer().make<Config>(Config)
}
produce(): ClientRepository {
return new (this.getClientRepositoryClass())()
}
match(something: unknown): boolean {
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
}
}

View File

@@ -0,0 +1,22 @@
import {ClientRepository, OAuth2Client, isOAuth2Client} from '../types'
import {Awaitable, ErrorWithContext, Maybe} from '../../../util'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
@Injectable()
export class ConfigClientRepository extends ClientRepository {
@Inject()
protected readonly config!: Config
find(id: string): Awaitable<Maybe<OAuth2Client>> {
const client = this.config.get(`oauth2.clients.${id}`)
if ( !isOAuth2Client(client) ) {
throw new ErrorWithContext('Invalid OAuth2 client configuration', {
id,
client,
})
}
return client
}
}

View File

@@ -0,0 +1,21 @@
import {isOAuth2Scope, OAuth2Scope, ScopeRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
import {Awaitable, Maybe} from '../../../util'
@Injectable()
export class ConfigScopeRepository extends ScopeRepository {
@Inject()
protected readonly config!: Config
find(id: string): Awaitable<Maybe<OAuth2Scope>> {
const scope = this.config.get(`oauth2.scopes.${id}`)
if ( isOAuth2Scope(scope) ) {
return scope
}
}
findByName(name: string): Awaitable<Maybe<OAuth2Scope>> {
return this.find(name)
}
}

View File

@@ -0,0 +1,88 @@
import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {Maybe} from '../../../util'
import {OAuth2TokenModel} from '../models/OAuth2TokenModel'
import {Config} from '../../../service/Config'
import * as jwt from 'jsonwebtoken'
import {Authenticatable} from '../../types'
@Injectable()
export class ORMTokenRepository extends TokenRepository {
@Inject()
protected readonly config!: Config
async find(id: string): Promise<Maybe<OAuth2Token>> {
const idNum = parseInt(id, 10)
if ( !isNaN(idNum) ) {
return OAuth2TokenModel.query()
.whereKey(idNum)
.first()
}
}
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
const expiration = this.config.safe('outh2.token.lifetimeSeconds')
.or(60 * 60 * 6)
.integer() * 1000
const token = new OAuth2TokenModel()
token.scope = scope
token.userId = String(user.getUniqueIdentifier())
token.clientId = client.id
token.issued = new Date()
token.expires = new Date(Math.floor(Date.now() + expiration))
await token.save()
return token
}
async encode(token: OAuth2Token): Promise<OAuth2TokenString> {
const secret = this.config.safe('oauth2.secret').string()
const payload = {
id: token.id,
userId: token.userId,
clientId: token.clientId,
iat: Math.floor(token.issued.valueOf() / 1000),
exp: Math.floor(token.expires.valueOf() / 1000),
...(token.scope ? { scope: token.scope } : {}),
}
const generated = await new Promise<string>((res, rej) => {
jwt.sign(payload, secret, {}, (err, gen) => {
if (err || err === null || !gen) {
rej(err || new Error('Unable to encode JWT.'))
} else {
res(gen)
}
})
})
return oauth2TokenString(generated)
}
async decode(token: OAuth2TokenString): Promise<Maybe<OAuth2Token>> {
const secret = this.config.safe('oauth2.secret').string()
const decoded = await new Promise<any>((res, rej) => {
jwt.verify(token, secret, {}, (err, payload) => {
if ( err ) {
rej(err)
} else {
res(payload)
}
})
})
const value = {
id: decoded.id,
userId: decoded.userId,
clientId: decoded.clientId,
issued: new Date(decoded.iat * 1000),
expires: new Date(decoded.exp * 1000),
...(decoded.scope ? { scope: decoded.scope } : {}),
}
if ( isOAuth2Token(value) ) {
return value
}
}
}

View File

@@ -0,0 +1,74 @@
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 {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
/**
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class RedemptionCodeRepositoryFactory extends AbstractFactory<RedemptionCodeRepository> {
protected get config(): Config {
return Container.getContainer().make<Config>(Config)
}
produce(): RedemptionCodeRepository {
return new (this.getRedemptionCodeRepositoryClass())()
}
match(something: unknown): boolean {
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
}
}

View File

@@ -0,0 +1,74 @@
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 {ConfigScopeRepository} from './ConfigScopeRepository'
/**
* A dependency injection factory that matches the abstract ScopeRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class ScopeRepositoryFactory extends AbstractFactory<ScopeRepository> {
protected get config(): Config {
return Container.getContainer().make<Config>(Config)
}
produce(): ScopeRepository {
return new (this.getScopeRepositoryClass())()
}
match(something: unknown): boolean {
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
}
}

View File

@@ -0,0 +1,74 @@
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 {ORMTokenRepository} from './ORMTokenRepository'
/**
* A dependency injection factory that matches the abstract TokenRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class TokenRepositoryFactory extends AbstractFactory<TokenRepository> {
protected get config(): Config {
return Container.getContainer().make<Config>(Config)
}
produce(): TokenRepository {
return new (this.getTokenRepositoryClass())()
}
match(something: unknown): boolean {
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
}
}

174
src/auth/server/types.ts Normal file
View File

@@ -0,0 +1,174 @@
import {Awaitable, hasOwnProperty, Maybe, TypeTag} from '../../util'
import {Authenticatable, AuthenticatableIdentifier} from '../types'
export enum OAuth2FlowType {
code = 'code',
}
// export const oauth2FlowTypes: OAuth2FlowType[] = Object.entries(OAuth2FlowType).map(([_, value]) => value)
export function isOAuth2FlowType(what: unknown): what is OAuth2FlowType {
return [OAuth2FlowType.code].includes(what as any)
}
export interface OAuth2Client {
id: string
display: string
secret: string
allowedFlows: OAuth2FlowType[]
allowedScopeIds: string[]
allowedRedirectUris: string[]
}
export function isOAuth2Client(what: unknown): what is OAuth2Client {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'id')
|| !hasOwnProperty(what, 'display')
|| !hasOwnProperty(what, 'secret')
|| !hasOwnProperty(what, 'allowedFlows')
|| !hasOwnProperty(what, 'allowedScopeIds')
|| !hasOwnProperty(what, 'allowedRedirectUris')
) {
return false
}
if ( typeof what.id !== 'string' || typeof what.display !== 'string' || typeof what.secret !== 'string' ) {
return false
}
if ( !Array.isArray(what.allowedScopeIds) || !what.allowedScopeIds.every(x => typeof x === 'string') ) {
return false
}
if ( !Array.isArray(what.allowedRedirectUris) || !what.allowedRedirectUris.every(x => typeof x === 'string') ) {
return false
}
return !(!Array.isArray(what.allowedFlows) || !what.allowedFlows.every(x => isOAuth2FlowType(x)))
}
export abstract class ClientRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Client>>
}
export interface OAuth2Scope {
id: string
name: string
description?: string
}
export function isOAuth2Scope(what: unknown): what is OAuth2Scope {
if ( typeof what !== 'object' || what === null ) {
return false
}
if ( !hasOwnProperty(what, 'id') || !hasOwnProperty(what, 'name') ) {
return false
}
if ( typeof what.id !== 'string' || typeof what.name !== 'string' ) {
return false
}
return !hasOwnProperty(what, 'description') || typeof what.description === 'string'
}
export abstract class ScopeRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Scope>>
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
}
export interface OAuth2Token {
id: string
userId: AuthenticatableIdentifier
clientId: string
issued: Date
expires: Date
scope?: string
}
export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string
export function oauth2TokenString(s: string): OAuth2TokenString {
return s as OAuth2TokenString
}
export function isOAuth2Token(what: unknown): what is OAuth2Token {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'id')
|| !hasOwnProperty(what, 'userId')
|| !hasOwnProperty(what, 'clientId')
|| !hasOwnProperty(what, 'issued')
|| !hasOwnProperty(what, 'expires')
) {
return false
}
if (
typeof what.id !== 'string'
|| !(typeof what.userId === 'string' || typeof what.userId === 'number')
|| typeof what.clientId !== 'string'
|| !(what.issued instanceof Date)
|| !(what.expires instanceof Date)
) {
return false
}
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
}
export abstract class TokenRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Token>>
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
abstract decode(token: OAuth2TokenString): Awaitable<Maybe<OAuth2Token>>
abstract encode(token: OAuth2Token): Awaitable<OAuth2TokenString>
}
export interface OAuth2RedemptionCode {
clientId: string
userId: AuthenticatableIdentifier
code: string
scope?: string
}
export function isOAuth2RedemptionCode(what: unknown): what is OAuth2RedemptionCode {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'clientId')
|| !hasOwnProperty(what, 'userId')
|| !hasOwnProperty(what, 'code')
) {
return false
}
if (
typeof what.clientId !== 'string'
|| !(typeof what.userId === 'number' || typeof what.userId === 'string')
|| typeof what.code !== 'string'
) {
return false
}
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
}
export abstract class RedemptionCodeRepository {
abstract find(code: string): Awaitable<Maybe<OAuth2RedemptionCode>>
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2RedemptionCode>
}

43
src/auth/types.ts Normal file
View File

@@ -0,0 +1,43 @@
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
/**
* Base class for entities that can be authenticated.
*/
export abstract class Authenticatable implements Rehydratable {
/** Get the globally-unique identifier of the user. */
abstract getUniqueIdentifier(): AuthenticatableIdentifier
/** Get the repository-unique identifier of the user. */
abstract getIdentifier(): AuthenticatableIdentifier
/** Get the human-readable identifier of the user. */
abstract getDisplay(): string
/** Attempt to validate a credential of the user. */
abstract validateCredential(credential: string): Awaitable<boolean>
abstract dehydrate(): Promise<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
}
/**
* Base class for a repository that stores and recalls users.
*/
export abstract class AuthenticatableRepository {
/** Look up the user by their unique identifier. */
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
/** Returns true if this repository supports registering users. */
abstract supportsRegistration(): boolean
/** Create a user in this repository from an external Authenticatable instance. */
abstract createFromExternal(user: Authenticatable): Awaitable<Authenticatable>
/** Create a user in this repository from basic credentials. */
abstract createFromCredentials(username: string, password: string): Awaitable<Authenticatable>
}

View File

@@ -169,8 +169,12 @@ export abstract class Directive extends AppClass {
const optionValues = this.parseOptions(options, argv)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e) {
this.nativeOutput(e.message)
} catch (e: unknown) {
if ( e instanceof Error ) {
this.nativeOutput(e.message)
this.error(e)
}
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
@@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
this.nativeOutput(` - ${e.context.value}`)
}
}
this.nativeOutput('\nUse --help for more info.')
}
}
@@ -463,4 +468,22 @@ export abstract class Directive extends AppClass {
protected nativeOutput(...outputs: any[]): void {
console.log(...outputs) // eslint-disable-line no-console
}
/**
* Get a promise that resolves after SIGINT is received, executing a
* callback beforehand.
* @param callback
* @protected
*/
protected async untilInterrupt(callback?: () => unknown): Promise<void> {
return new Promise<void>(res => {
process.on('SIGINT', async () => {
if ( callback ) {
await callback()
}
res()
})
})
}
}

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: CommandLine) => {
cli.registerDirective(target as Instantiable<Directive>)
})
} else {
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
}
}
}

View File

@@ -0,0 +1,72 @@
import {Directive, OptionDefinition} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
import {HTTPMethod} from '../../http/lifecycle/Request'
@Injectable()
export class RouteDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'Get information about a specific route'
}
getKeywords(): string | string[] {
return ['route']
}
getOptions(): OptionDefinition[] {
return [
'{route} | the path of the route',
'--method -m {value} | the HTTP method of the route',
]
}
async handle(): Promise<void> {
const method: string | undefined = this.option('method')
?.toLowerCase()
?.trim()
const route: string = this.option('route')
.toLowerCase()
.trim()
const matched = this.routing.getCompiled()
.filter(match => {
if ( !method ) {
return match.getRoute().trim() === route
}
return (
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|| match.match(method as HTTPMethod, route)
)
})
.some(match => {
const displays = match.getDisplays()
.map<[string, string]>(ware => [ware.stage, ware.display])
if ( displays.isEmpty() ) {
return
}
const maxLen = displays.max(x => x[1].length)
const table = new Table({
head: ['Stage', 'Handler'],
colWidths: [10, maxLen + 2],
})
displays.each(x => table.push(x))
this.info(`\nRoute: ${match}\n\n${table}`)
return true
})
if ( !matched ) {
this.error('No matching routes found.')
}
}
}

View File

@@ -0,0 +1,41 @@
import {Directive} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
@Injectable()
export class RoutesDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'List routes registered in the application'
}
getKeywords(): string | string[] {
return ['routes']
}
async handle(): Promise<void> {
const compiled = this.routing.getCompiled()
const maxRouteLength = compiled.strings().max('length')
const maxHandlerLength = compiled.mapCall('getHandlerDisplay')
.whereDefined()
.max('length')
const maxNameLength = compiled.mapCall('getAlias')
.whereDefined()
.max('length')
const rows = compiled.map(route => [String(route), route.getHandlerDisplay(), route.getAlias() || ''])
const table = new Table({
head: ['Route', 'Handler', 'Name'],
colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
})
table.push(...rows.toArray())
this.info('\n' + table)
}
}

View File

@@ -34,6 +34,7 @@ export class ShellDirective extends Directive {
async handle(): Promise<void> {
const state: any = {
app: this.app(),
lib: await import('../../index'),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
}

View File

@@ -0,0 +1,64 @@
import {Directive, OptionDefinition} from '../../Directive'
import {Inject, Injectable} from '../../../di'
import {Bus, PushedToQueue, Queue} from '../../../support/bus'
import {Queueables} from '../../../service/Queueables'
@Injectable()
export class ListenDirective extends Directive {
@Inject()
protected readonly queue!: Queue
@Inject()
protected readonly queueables!: Queueables
@Inject()
protected readonly bus!: Bus
getDescription(): string {
return 'listen for jobs pushed to the queue and attempt to execute them'
}
getKeywords(): string | string[] {
return 'queue-listen'
}
getOptions(): OptionDefinition[] {
return []
}
async handle(): Promise<void> {
this.info('Subscribing to queue events...')
await this.bus.subscribe(PushedToQueue, async () => {
// A new job has been pushed to the queue, so try to pop it and execute it.
// We may get undefined if some other worker is running and picked up this job first.
await this.tryExecuteJob()
})
this.info('Setting periodic poll...')
const handle = setInterval(async () => {
await this.tryExecuteJob()
}, 5000)
this.info('Listening for jobs...')
await this.untilInterrupt()
this.info('Shutting down...')
clearInterval(handle)
}
protected async tryExecuteJob(): Promise<void> {
try {
const job = await this.queue.pop()
if ( !job ) {
return // Some other worker already picked up this job
}
this.info(`Executing: ${job.constructor?.name || 'unknown job'}`)
await job.execute()
this.success('Execution finished.')
} catch (e: unknown) {
this.error('Failed to execute job.')
this.error(e)
}
}
}

View File

@@ -0,0 +1,43 @@
import {Directive, OptionDefinition} from '../../Directive'
import {Inject, Injectable} from '../../../di'
import {Queue} from '../../../support/bus'
import {Queueables} from '../../../service/Queueables'
@Injectable()
export class WorkDirective extends Directive {
@Inject()
protected readonly queue!: Queue
@Inject()
protected readonly queueables!: Queueables
getDescription(): string {
return 'pop a single item from the queue and execute it'
}
getKeywords(): string | string[] {
return 'queue-work'
}
getOptions(): OptionDefinition[] {
return []
}
async handle(): Promise<void> {
try {
const queueable = await this.queue.pop()
if ( !queueable ) {
this.info('There are no items in the queue.')
return
}
this.info(`Fetched 1 item from the queue`)
await queueable.execute()
this.success('Executed 1 item from the queue')
} catch (e: unknown) {
this.error('Failed to execute queueable:')
this.error(e)
process.exitCode = 1
}
}
}

View File

@@ -11,3 +11,8 @@ export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective'
export * from './directive/TemplateDirective'
export * from './directive/UsageDirective'
export * from './decorators'
export * from './directive/queue/ListenDirective'
export * from './directive/queue/WorkDirective'

View File

@@ -1,12 +1,16 @@
import {Unit} from '../../lifecycle/Unit'
import {Logging} from '../../service/Logging'
import {Singleton, Inject} from '../../di/decorator/injection'
import {Singleton, Inject} from '../../di'
import {CommandLine} from './CommandLine'
import {UsageDirective} from '../directive/UsageDirective'
import {Directive} from '../Directive'
import {ShellDirective} from '../directive/ShellDirective'
import {TemplateDirective} from '../directive/TemplateDirective'
import {RunDirective} from '../directive/RunDirective'
import {RoutesDirective} from '../directive/RoutesDirective'
import {RouteDirective} from '../directive/RouteDirective'
import {WorkDirective} from '../directive/queue/WorkDirective'
import {ListenDirective} from '../directive/queue/ListenDirective'
/**
* Unit that takes the place of the final unit in the application that handles
@@ -42,6 +46,10 @@ export class CommandLineApplication extends Unit {
this.cli.registerDirective(ShellDirective)
this.cli.registerDirective(TemplateDirective)
this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective)
this.cli.registerDirective(WorkDirective)
this.cli.registerDirective(ListenDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()

View File

@@ -1,29 +1,114 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable} from './types'
import {
DependencyKey,
InstanceRef,
Instantiable,
isInstantiable,
StaticClass,
StaticInstantiable,
TypedDependencyKey,
} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
import {Awaitable, collect, Collection, ErrorWithContext, globalRegistry, hasOwnProperty, logIfDebugging} from '../util'
import {Factory} from './factory/Factory'
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
import {ClosureFactory} from './factory/ClosureFactory'
import NamedFactory from './factory/NamedFactory'
import SingletonFactory from './factory/SingletonFactory'
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
/**
* Singletons that implement this interface receive callbacks for
* structural container events.
*/
export interface AwareOfContainerLifecycle {
awareOfContainerLifecycle: true
/** Called when this key is realized by its parent container. */
onContainerRealize?(): Awaitable<unknown>
/** Called before the parent container of this instance is destroyed. */
onContainerDestroy?(): Awaitable<unknown>
/** Called before an instance of a key is released from the container. */
onContainerRelease?(): Awaitable<unknown>
}
export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle {
return Boolean(
typeof what === 'object'
&& what !== null
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
&& what.awareOfContainerLifecycle,
)
}
/**
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Set to true when we're realizing a container.
* Used to prevent infinite recursion when `getContainer()` is accidentally called
* from somewhere within the `realizeContainer()` call.
*/
protected static realizingContainer = false
/**
* List of dependency keys currently being `make`'d as a reverse stack.
* This is used to detect dependency cycles.
* @protected
*/
protected static makeStack?: Collection<DependencyKey>
/**
* The 100 most recent dependency keys that were `make`'d. Used to help with
* debugging cyclic dependency errors.
* @protected
*/
protected static makeHistory?: Collection<DependencyKey>
/**
* 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()
if ( this.realizingContainer ) {
throw new ErrorWithContext('Attempted getContainer call during container realization.')
}
this.realizingContainer = true
const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container)
this.realizingContainer = false
return container
}
@@ -42,11 +127,55 @@ export class Container {
*/
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
/**
* Collection of static-class overrides registered with this container.
* @protected
*/
protected staticOverrides: Collection<{ base: StaticInstantiable<any>, override: StaticInstantiable<any> }> = new Collection<{base: StaticInstantiable<any>; override: StaticInstantiable<any>}>()
/**
* 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}>();
/**
* Collection of created objects that should have lifecycle events called on them, if they still exist.
* @protected
*/
protected waitingLifecycleCallbacks: Collection<WeakRef<AwareOfContainerLifecycle>> = new Collection()
constructor() {
this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this)
}
/**
* Purge all factories and instances of the given key from this container.
* @param key
*/
purge(key: DependencyKey): this {
this.factories = this.factories.filter(x => !x.match(key))
this.release(key)
return this
}
/**
* Remove all stored instances of the given key from this container.
* @param key
*/
release(key: DependencyKey): this {
this.instances = this.instances.filter(x => {
if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) {
x.value.onContainerRelease?.()
}
return x.key !== key
})
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
@@ -61,6 +190,52 @@ export class Container {
return this
}
/**
* Register a static class as an override of some base class.
* @param base
* @param override
*/
registerStaticOverride<T>(base: StaticInstantiable<T>, override: StaticInstantiable<T>): this {
if ( this.hasStaticOverride(base) ) {
throw new DuplicateFactoryKeyError(base)
}
this.staticOverrides.push({
base,
override,
})
return this
}
/** Returns true if a static override exists for the given base class. */
hasStaticOverride<T>(base: StaticInstantiable<T>): boolean {
return this.staticOverrides.where('base', '=', base).isNotEmpty()
}
/**
* Get the static class overriding the base class.
* @param base
*/
getStaticOverride<T>(base: StaticInstantiable<T>): StaticInstantiable<T> {
const override = this.staticOverrides.firstWhere('base', '=', base)
if ( override ) {
return override.override
}
return base
}
/**
* Get the registered instance of the static override of a given class.
* @param base
* @param parameters
*/
makeByStaticOverride<T>(base: StaticInstantiable<T>, ...parameters: any[]): T {
const key = this.getStaticOverride(base)
return this.make(key, ...parameters)
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
@@ -113,7 +288,7 @@ export class Container {
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T): this {
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
if ( this.resolve(staticClass) ) {
throw new DuplicateFactoryKeyError(staticClass)
}
@@ -147,6 +322,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
@@ -175,7 +370,7 @@ export class Container {
if ( factory ) {
return factory
} else {
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories)
}
}
@@ -209,6 +404,19 @@ export class Container {
value: newInstance,
})
if ( isAwareOfContainerLifecycle(newInstance) ) {
newInstance.onContainerRealize?.()
}
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
if ( waiter.key === key ) {
waiter.callback(newInstance)
return false
}
return true
})
return newInstance
}
@@ -219,6 +427,8 @@ export class Container {
* @param {array} parameters
*/
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack)
// Create the dependencies for the factory
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
const dependencies = keys.map<ResolvedDependency>(req => {
@@ -246,12 +456,18 @@ export class Container {
// Produce a new instance
const inst = factory.produce(constructorArguments, params.reverse().all())
logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory)
factory.getInjectedProperties().each(dependency => {
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
if ( dependency.key && inst ) {
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
}
})
if ( isAwareOfContainerLifecycle(inst) ) {
this.waitingLifecycleCallbacks.push(new WeakRef<AwareOfContainerLifecycle>(inst))
}
return inst
}
@@ -265,13 +481,101 @@ export class Container {
* @param {...any} parameters
*/
make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( this.hasKey(target) ) {
return this.resolveAndCreate(target, ...parameters)
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
return this.produceFactory(new Factory(target), parameters)
} else {
throw new TypeError(`Invalid or unknown make target: ${target}`)
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
Container.makeStack.push(target)
if ( Container.makeHistory.length > 100 ) {
Container.makeHistory = Container.makeHistory.slice(1, 100)
}
Container.makeHistory.push(target)
this.checkForMakeCycles()
try {
if (this.hasKey(target)) {
const realized = this.resolveAndCreate(target, ...parameters)
Container.makeStack.pop()
return realized
} else if (typeof target !== 'string' && isInstantiable(target)) {
const realized = this.produceFactory(new Factory(target), parameters)
Container.makeStack.pop()
return realized
}
} catch (e: unknown) {
Container.makeStack.pop()
throw e
}
Container.makeStack.pop()
throw new TypeError(`Invalid or unknown make target: ${target}`)
}
/**
* Check the `makeStack` for duplicates and throw an error if a dependency cycle is
* detected. This is used to prevent infinite mutual recursion when cyclic dependencies
* occur.
* @protected
*/
protected checkForMakeCycles(): void {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
if ( Container.makeStack.unique().length !== Container.makeStack.length ) {
const displayKey = (key: DependencyKey) => {
if ( typeof key === 'string' ) {
return 'key: `' + key + '`'
} else {
return `key: ${key.name}`
}
}
const makeStack = Container.makeStack
.reverse()
.map(displayKey)
const makeHistory = Container.makeHistory
.reverse()
.map(displayKey)
console.error('Make Stack:') // eslint-disable-line no-console
console.error(makeStack.join('\n')) // eslint-disable-line no-console
console.error('Make History:') // eslint-disable-line no-console
console.error(makeHistory.join('\n')) // eslint-disable-line no-console
throw new ErrorWithContext('Cyclic dependency chain detected', {
makeStack,
makeHistory,
})
}
}
/**
* Create a new instance of the dependency key using this container, ignoring any pre-existing instances
* in this container.
* @param key
* @param parameters
*/
makeNew<T>(key: TypedDependencyKey<T>, ...parameters: any[]): T {
if ( isInstantiable(key) ) {
const result = this.produceFactory(new Factory(key), parameters)
if ( isAwareOfContainerLifecycle(result) ) {
result.onContainerRealize?.()
}
return result
}
throw new TypeError(`Invalid or unknown make target: ${key}`)
}
/**
@@ -288,6 +592,21 @@ export class Container {
return factory.getDependencyKeys().pluck('key')
}
/**
* Perform any cleanup necessary to destroy this container instance.
*/
destroy(): void {
this.waitingLifecycleCallbacks
.mapCall('deref')
.whereDefined()
.each(inst => {
if ( isAwareOfContainerLifecycle(inst) ) {
inst.onContainerRelease?.()
inst.onContainerDestroy?.()
}
})
}
/**
* Given a different container, copy the factories and instances from this container over to it.
* @param container

View File

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

37
src/di/InjectionAware.ts Normal file
View File

@@ -0,0 +1,37 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
/**
* Base class for Injection-aware classes that automatically
* pass along their configured container to instances created
* via their `make` method.
*/
export class InjectionAware {
private ci: Container
constructor() {
this.ci = Container.getContainer()
}
/** Set the container for this instance. */
public setContainer(ci: Container): this {
this.ci = ci
return this
}
/** Get the container for this instance. */
public getContainer(): Container {
return this.ci
}
/** Instantiate a new injectable using the container. */
public make<T>(target: TypedDependencyKey<T>, ...parameters: any[]): T {
const inst = this.ci.make<T>(target, ...parameters)
if ( inst instanceof InjectionAware ) {
inst.setContainer(this.ci)
}
return inst
}
}

View File

@@ -1,5 +1,6 @@
import {Container, MaybeDependency, MaybeFactory} from './Container'
import {DependencyKey} from './types'
import {DependencyKey, Instantiable, StaticClass} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
/**
* A container that uses some parent container as a base, but
@@ -30,6 +31,8 @@ export class ScopedContainer extends Container {
return new ScopedContainer(container)
}
private resolveParentScope = true
constructor(
private parentContainer: Container,
) {
@@ -38,11 +41,11 @@ export class ScopedContainer extends Container {
}
hasInstance(key: DependencyKey): boolean {
return super.hasInstance(key) || this.parentContainer.hasInstance(key)
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
}
hasKey(key: DependencyKey): boolean {
return super.hasKey(key) || this.parentContainer.hasKey(key)
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
}
getExistingInstance(key: DependencyKey): MaybeDependency {
@@ -51,7 +54,9 @@ export class ScopedContainer extends Container {
return inst
}
return this.parentContainer.getExistingInstance(key)
if ( this.resolveParentScope ) {
return this.parentContainer.getExistingInstance(key)
}
}
resolve(key: DependencyKey): MaybeFactory<any> {
@@ -60,6 +65,77 @@ export class ScopedContainer extends Container {
return factory
}
return this.parentContainer?.resolve(key)
if ( this.resolveParentScope ) {
return this.parentContainer.resolve(key)
}
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.register(dependency))
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: DependencyKey, producer: () => any): this {
return this.withoutParentScopes(() => super.registerProducer(name, producer))
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
}
/**
* Register a value as a singleton in the container. It will not be instantiated, but
* can be injected by its unique name.
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton<T>(key: DependencyKey, value: T): this {
return this.withoutParentScopes(() => super.registerSingleton(key, value))
}
/**
* Register a static class to the container along with its already-instantiated
* instance that will be used to resolve the class.
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
}
/**
* Register a given factory with the container.
* @param {AbstractFactory} factory
*/
registerFactory(factory: AbstractFactory<unknown>): this {
return this.withoutParentScopes(() => super.registerFactory(factory))
}
/**
* Execute a closure on this container, disabling parent-resolution.
* Effectively, the closure will have access to this container as if
* it were NOT a scoped container, and only contained its factories.
* @param closure
*/
withoutParentScopes<T>(closure: () => T): T {
const oldResolveParentScope = this.resolveParentScope
this.resolveParentScope = false
const value: T = closure()
this.resolveParentScope = oldResolveParentScope
return value
}
}

11
src/di/constructable.ts Normal file
View File

@@ -0,0 +1,11 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
import {Pipeline} from '../util'
export type Constructable<T> = Pipeline<Container, T>
export function constructable<T>(key: TypedDependencyKey<T>): Constructable<T> {
return new Pipeline<Container, T>(
container => container.make(key),
)
}

View File

@@ -1,5 +1,5 @@
import 'reflect-metadata'
import {collect, Collection} from '../../util'
import {collect, Collection, logIfDebugging} from '../../util'
import {
DependencyKey,
DependencyRequirement,
@@ -10,7 +10,7 @@ import {
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
PropertyDependency,
} from '../types'
import {Container} from '../Container'
import {ContainerBlueprint} from '../ContainerBlueprint'
/**
* Get a collection of dependency requirements for the given target object.
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
* If a `key` is specified, that DependencyKey will be injected.
* 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)
}
}
@@ -145,10 +153,23 @@ export const Singleton = (name?: string): ClassDecorator => {
Injectable()(target)
if ( name ) {
Container.getContainer().registerNamed(name, target)
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
} else {
Container.getContainer().register(target)
ContainerBlueprint.getContainerBlueprint().register(target)
}
}
}
}
/**
* Register a factory class directly with any created containers.
* @constructor
*/
export const FactoryProducer = (): ClassDecorator => {
return (target) => {
logIfDebugging('extollo.di.injector', 'Registering factory producer for target:', target)
if ( isInstantiable(target) ) {
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
}
}
}

View File

@@ -7,8 +7,11 @@ export * from './factory/Factory'
export * from './factory/NamedFactory'
export * from './factory/SingletonFactory'
export * from './ContainerBlueprint'
export * from './Container'
export * from './ScopedContainer'
export * from './types'
export * from './decorator/injection'
export * from './InjectionAware'
export * from './constructable'

View File

@@ -23,10 +23,31 @@ 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.
*/
export type StaticClass<T, T2> = Function & {prototype: T} & T2 // eslint-disable-line @typescript-eslint/ban-types
export type StaticClass<T, T2, TCtorParams extends any[] = any[]> = T2 & StaticThis<T, TCtorParams> // eslint-disable-line @typescript-eslint/ban-types
/**
* Quasi-reference to a `this` type w/in a static member.
* @see https://github.com/microsoft/TypeScript/issues/5863#issuecomment-302861175
*/
export type StaticThis<T, TCtorParams extends any[]> = { new (...args: TCtorParams): T }
/**
* Type that identifies a value as a static class that instantiates to itself
*/
export type StaticInstantiable<T> = StaticClass<T, Instantiable<T>>
/**
* Returns true if the parameter is a static class.
@@ -41,6 +62,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 +84,7 @@ export interface DependencyRequirement {
export interface PropertyDependency {
key: DependencyKey,
property: string | symbol,
debug?: boolean,
}
/**

View File

@@ -1,62 +0,0 @@
import {Container, Injectable, InjectParam} from '../di'
import {Request} from '../http/lifecycle/Request'
import {Valid, ValidationRules} from './rules/types'
import {Validator} from './Validator'
import {AppClass} from '../lifecycle/AppClass'
import {DataContainer} from '../http/lifecycle/Request'
/**
* Base class for defining reusable validators for request routes.
* If instantiated with a container, it must be a request-level container,
* but the type interface allows any data-container to be used when creating
* manually.
*
* You should mark implementations of this class as singleton to avoid
* re-validating the input data every time it is accessed.
*
* @example
* ```ts
* // Instantiate with the request:
* const data = <MyFormRequest> request.make(MyFormRequest)
*
* // Instantiate with some container:
* const data = new MyFormRequest(someDataContainer)
* ```
*/
@Injectable()
export abstract class FormRequest<T> extends AppClass {
/** The cached validation result. */
protected cachedResult?: Valid<T>
constructor(
@InjectParam(Request)
protected readonly data: DataContainer,
) {
super()
}
protected container(): Container {
return (this.data as unknown) as Container
}
/**
* The validation rules that should be applied to the request to guarantee
* that it contains the given data type.
* @protected
*/
protected abstract getRules(): ValidationRules | Promise<ValidationRules>
/**
* Validate and get the request input. Throws a validation error on fail.
* Internally, caches the result after the first validation. So, singleton
* validators will avoid re-processing their rules every time.
*/
public async get(): Promise<Valid<T>> {
if ( !this.cachedResult ) {
const validator = <Validator<T>> this.make(Validator, await this.getRules())
this.cachedResult = await validator.validate(this.data.input())
}
return this.cachedResult
}
}

View File

@@ -1,109 +0,0 @@
import {Valid, ValidationResult, ValidationRules, ValidatorFunction, ValidatorFunctionParams} from './rules/types'
import {Messages, ErrorWithContext, dataWalkUnsafe, dataSetUnsafe} from '../util'
/**
* An error thrown thrown when an object fails its validation.
*/
export class ValidationError<T> extends ErrorWithContext {
constructor(
/** The original input data. */
public readonly data: unknown,
/** The validator instance used. */
public readonly validator: Validator<T>,
/** Validation error messages, by field. */
public readonly errors: Messages,
) {
super('One or more fields were invalid.', { data,
messages: errors.all() })
}
}
/**
* A class to validate arbitrary data using functional rules.
*/
export class Validator<T> {
constructor(
/** The rules used to validate input objects. */
protected readonly rules: ValidationRules,
) {}
/**
* Attempt to validate the input data.
* If it is valid, it is type aliased as Valid<T>.
* If it is invalid, a ValidationError is thrown.
* @param data
*/
public async validate(data: unknown): Promise<Valid<T>> {
const messages = await this.validateAndGetErrors(data)
if ( messages.any() ) {
throw new ValidationError<T>(data, this, messages)
}
return data as Valid<T>
}
/**
* Returns true if the given data is valid and type aliases it as Valid<T>.
* @param data
*/
public async isValid(data: unknown): Promise<boolean> {
return !(await this.validateAndGetErrors(data)).any()
}
/**
* Apply the validation rules to the data object and return any error messages.
* @param data
* @protected
*/
protected async validateAndGetErrors(data: unknown): Promise<Messages> {
const messages = new Messages()
const params: ValidatorFunctionParams = { data }
for ( const key in this.rules ) {
if ( !Object.prototype.hasOwnProperty.call(this.rules, key) ) {
continue
}
// This walks over all of the values in the data structure using the nested
// key notation. It's not type-safe, but neither is the original input object
// yet, so it's useful here.
for ( const walkEntry of dataWalkUnsafe<any>(data as any, key) ) {
let [entry, dataKey] = walkEntry // eslint-disable-line prefer-const
const rules = (Array.isArray(this.rules[key]) ? this.rules[key] : [this.rules[key]]) as ValidatorFunction[]
for ( const rule of rules ) {
const result: ValidationResult = await rule(dataKey, entry, params)
if ( !result.valid ) {
let errors = ['is invalid']
if ( Array.isArray(result.message) && result.message.length ) {
errors = result.message
} else if ( !Array.isArray(result.message) && result.message ) {
errors = [result.message]
}
for ( const error of errors ) {
if ( !messages.has(dataKey, error) ) {
messages.put(dataKey, error)
}
}
}
if ( result.valid && result.castValue ) {
entry = result.castValue
data = dataSetUnsafe(dataKey, entry, data as any)
}
if ( result.stopValidation ) {
break // move on to the next field
}
}
}
}
return messages
}
}

View File

@@ -1,9 +0,0 @@
export * from './rules/types'
export * as Rule from './rules/rules'
export * from './unit/Forms'
export * from './Validator'
export * from './FormRequest'
export * from './middleware'

View File

@@ -1,34 +0,0 @@
import {Instantiable} from '../di'
import {FormRequest} from './FormRequest'
import {ValidationError} from './Validator'
import {ResponseObject, RouteHandler} from '../http/routing/Route'
import {Request} from '../http/lifecycle/Request'
/**
* Builds a middleware function that validates a request's input against
* the given form request class and registers the FormRequest class into
* the request container.
*
* @example
* ```typescript
* Route.group(...).pre(formRequest(MyFormRequestClass))
* ```
*
* @param formRequestClass
*/
export function formRequest<T>(formRequestClass: Instantiable<FormRequest<T>>): RouteHandler {
return async function formRequestRouteHandler(request: Request): Promise<ResponseObject> {
const formRequestInstance = <FormRequest<T>> request.make(formRequestClass)
try {
await formRequestInstance.get()
request.registerSingletonInstance<FormRequest<T>>(formRequestClass, formRequestInstance)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return e.errors.toJSON()
}
throw e
}
}
}

View File

@@ -1,150 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
/** Requires the input value to be an array. */
function is(fieldName: string, inputValue: unknown): ValidationResult {
if ( Array.isArray(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be an array',
}
}
/** Requires the values in the input value array to be distinct. */
function distinct(fieldName: string, inputValue: unknown): ValidationResult {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && (new Set(inputValue)).size === inputValue.length ) {
return { valid: true }
}
return {
valid: false,
message: 'must not contain duplicate values',
}
}
/**
* Builds a validator function that requires the input array to contain the given value.
* @param value
*/
function includes(value: unknown): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.includes(value) ) {
return { valid: true }
}
return {
valid: false,
message: `must include ${value}`,
}
}
}
/**
* Builds a validator function that requires the input array NOT to contain the given value.
* @param value
*/
function excludes(value: unknown): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && !inputValue.includes(value) ) {
return { valid: true }
}
return {
valid: false,
message: `must not include ${value}`,
}
}
}
/**
* Builds a validator function that requires the input array to have exactly `len` many entries.
* @param len
*/
function length(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length === len ) {
return { valid: true }
}
return {
valid: false,
message: `must be exactly of length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input array to have at least `len` many entries.
* @param len
*/
function lengthMin(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length >= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input array to have at most `len` many entries.
* @param len
*/
function lengthMax(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length <= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most length ${len}`,
}
}
}
export const Arr = {
is,
distinct,
includes,
excludes,
length,
lengthMin,
lengthMax,
}

View File

@@ -1,80 +0,0 @@
import {infer as inferUtil} from '../../util'
import {ValidationResult} from './types'
/** Attempt to infer the native type of a string value. */
function infer(fieldName: string, inputValue: unknown): ValidationResult {
return {
valid: true,
castValue: typeof inputValue === 'string' ? inferUtil(inputValue) : inputValue,
}
}
/**
* Casts the input value to a boolean.
* Note that this assumes the value may be boolish. The strings "true", "True",
* "TRUE", and "1" evaluate to `true`, while "false", "False", "FALSE", and "0"
* evaluate to `false`.
* @param fieldName
* @param inputValue
*/
function boolean(fieldName: string, inputValue: unknown): ValidationResult {
let castValue = Boolean(inputValue)
if ( ['true', 'True', 'TRUE', '1'].includes(String(inputValue)) ) {
castValue = true
}
if ( ['false', 'False', 'FALSE', '0'].includes(String(inputValue)) ) {
castValue = false
}
return {
valid: true,
castValue,
}
}
/** Casts the input value to a string. */
function string(fieldName: string, inputValue: unknown): ValidationResult {
return {
valid: true,
castValue: String(inputValue),
}
}
/** Casts the input value to a number, if it is numerical. Fails otherwise. */
function numeric(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseFloat(String(inputValue))) ) {
return {
valid: true,
castValue: parseFloat(String(inputValue)),
}
}
return {
valid: false,
message: 'must be numeric',
}
}
/** Casts the input value to an integer. Fails otherwise. */
function integer(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseInt(String(inputValue), 10)) ) {
return {
valid: true,
castValue: parseInt(String(inputValue), 10),
}
}
return {
valid: false,
message: 'must be an integer',
}
}
export const Cast = {
infer,
boolean,
string,
numeric,
integer,
}

View File

@@ -1,210 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
/**
* Builds a validator function that requires the input value to be greater than some value.
* @param value
*/
function greaterThan(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) > value ) {
return { valid: true }
}
return {
valid: false,
message: `must be greater than ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be at least some value.
* @param value
*/
function atLeast(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) >= value ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be less than some value.
* @param value
*/
function lessThan(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) < value ) {
return { valid: true }
}
return {
valid: false,
message: `must be less than ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be at most some value.
* @param value
*/
function atMost(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) <= value ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to have exactly `num` many digits.
* @param num
*/
function digits(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length === num ) {
return { valid: true }
}
return {
valid: false,
message: `must have exactly ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to have at least `num` many digits.
* @param num
*/
function digitsMin(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length >= num ) {
return { valid: true }
}
return {
valid: false,
message: `must have at least ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to have at most `num` many digits.
* @param num
*/
function digitsMax(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length <= num ) {
return { valid: true }
}
return {
valid: false,
message: `must have at most ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to end with the given number sequence.
* @param num
*/
function ends(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).endsWith(String(num)) ) {
return { valid: true }
}
return {
valid: false,
message: `must end with "${num}"`,
}
}
}
/**
* Builds a validator function that requires the input value to begin with the given number sequence.
* @param num
*/
function begins(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).startsWith(String(num)) ) {
return { valid: true }
}
return {
valid: false,
message: `must begin with "${num}"`,
}
}
}
/**
* Builds a validator function that requires the input value to be a multiple of the given number.
* @param num
*/
function multipleOf(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( parseFloat(String(inputValue)) % num === 0 ) {
return { valid: true }
}
return {
valid: false,
message: `must be a multiple of ${num}`,
}
}
}
/** Requires the input value to be even. */
function even(fieldName: string, inputValue: unknown): ValidationResult {
if ( parseFloat(String(inputValue)) % 2 === 0 ) {
return { valid: true }
}
return {
valid: false,
message: 'must be even',
}
}
/** Requires the input value to be odd. */
function odd(fieldName: string, inputValue: unknown): ValidationResult {
if ( parseFloat(String(inputValue)) % 2 === 0 ) {
return { valid: true }
}
return {
valid: false,
message: 'must be odd',
}
}
export const Num = {
greaterThan,
atLeast,
lessThan,
atMost,
digits,
digitsMin,
digitsMax,
ends,
begins,
multipleOf,
even,
odd,
}

View File

@@ -1,191 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
import {UniversalPath} from '../../util'
/** Requires the given input value to be some form of affirmative boolean. */
function accepted(fieldName: string, inputValue: unknown): ValidationResult {
if ( ['yes', 'Yes', 'YES', 1, true, 'true', 'True', 'TRUE'].includes(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be accepted',
}
}
/** Requires the given input value to be some form of boolean. */
function boolean(fieldName: string, inputValue: unknown): ValidationResult {
const boolish = ['true', 'True', 'TRUE', '1', 'false', 'False', 'FALSE', '0', true, false, 1, 0]
if ( boolish.includes(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be true or false',
}
}
/** Requires the input value to be of type string. */
function string(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue === 'string' ) {
return { valid: true }
}
return {
valid: false,
message: 'must be a string',
}
}
/** Requires the given input value to be present and non-nullish. */
function required(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue !== 'undefined' && inputValue !== null && inputValue !== '' ) {
return { valid: true }
}
return {
valid: false,
message: 'is required',
stopValidation: true,
}
}
/** Alias of required(). */
function present(fieldName: string, inputValue: unknown): ValidationResult {
return required(fieldName, inputValue)
}
/** Alias of required(). */
function filled(fieldName: string, inputValue: unknown): ValidationResult {
return required(fieldName, inputValue)
}
/** Requires the given input value to be absent or nullish. */
function prohibited(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue === 'undefined' || inputValue === null || inputValue === '' ) {
return { valid: true }
}
return {
valid: false,
message: 'is not allowed',
stopValidation: true,
}
}
/** Alias of prohibited(). */
function absent(fieldName: string, inputValue: unknown): ValidationResult {
return prohibited(fieldName, inputValue)
}
/** Alias of prohibited(). */
function empty(fieldName: string, inputValue: unknown): ValidationResult {
return prohibited(fieldName, inputValue)
}
/**
* Builds a validator function that requires the given input to be found in an array of values.
* @param values
*/
function foundIn(values: any[]): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( values.includes(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: `must be one of: ${values.join(', ')}`,
}
}
}
/**
* Builds a validator function that requires the given input NOT to be found in an array of values.
* @param values
*/
function notFoundIn(values: any[]): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( values.includes(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: `must be one of: ${values.join(', ')}`,
}
}
}
/** Requires the input value to be number-like. */
function numeric(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseFloat(String(inputValue))) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be numeric',
}
}
/** Requires the given input value to be integer-like. */
function integer(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseInt(String(inputValue), 10)) && parseInt(String(inputValue), 10) === parseFloat(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be an integer',
}
}
/** Requires the given input value to be a UniversalPath. */
function file(fieldName: string, inputValue: unknown): ValidationResult {
if ( inputValue instanceof UniversalPath ) {
return { valid: true }
}
return {
valid: false,
message: 'must be a file',
}
}
/**
* A special validator function that marks a field as optional.
* If the value of the field is nullish, no further validation rules will be applied.
* If it is non-nullish, validation will continue.
* @param fieldName
* @param inputValue
*/
function optional(fieldName: string, inputValue: unknown): ValidationResult {
if ( inputValue ?? true ) {
return {
valid: true,
stopValidation: true,
}
}
return { valid: true }
}
export const Is = {
accepted,
boolean,
string,
required,
present,
filled,
prohibited,
absent,
empty,
foundIn,
notFoundIn,
numeric,
integer,
file,
optional,
}

View File

@@ -1,31 +0,0 @@
/*
import {Injectable} from '@extollo/di'
import {Validator} from '../Validator'
import {ValidationResult} from "../types";
@Injectable()
export class DateValidator extends Validator {
protected names: string[] = [
'date',
'date.after',
'date.at_least',
'date.before',
'date.at_most',
'date.equals',
'date.format',
]
public matchName(name: string): boolean {
return this.names.includes(name)
}
validate(fieldName: string, inputValue: any, params: { name: string; params: any }): ValidationResult {
switch ( params.name ) {
}
return { valid: false }
}
}
*/

View File

@@ -1,5 +0,0 @@
export { Arr } from './arrays'
export { Cast } from './inference'
export { Num } from './numeric'
export { Is } from './presence'
export { Str } from './strings'

View File

@@ -1,245 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
import {isJSON} from '../../util'
/**
* String-related validation rules.
*/
const regexes: {[key: string]: RegExp} = {
'string.is.alpha': /[a-zA-Z]*/,
'string.is.alpha_num': /[a-zA-Z0-9]*/,
'string.is.alpha_dash': /[a-zA-Z-]*/,
'string.is.alpha_score': /[a-zA-Z_]*/,
'string.is.alpha_num_dash_score': /[a-zA-Z\-_0-9]*/,
'string.is.email': /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)])/, // eslint-disable-line no-control-regex
'string.is.ip': /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/,
'string.is.ip.v4': /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/,
'string.is.ip.v6': /(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))/,
'string.is.mime': /^(?=[-a-z]{1,127}\/[-.a-z0-9]{1,127}$)[a-z]+(-[a-z]+)*\/[a-z0-9]+([-.][a-z0-9]+)*$/,
'string.is.url': /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/,
'string.is.uuid': /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,
}
function validateRex(key: string, inputValue: unknown, message: string): ValidationResult {
if ( regexes[key].test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message,
}
}
/** Requires the input value to be alphabetical characters only. */
function alpha(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha', inputValue, 'must be alphabetical only')
}
/** Requires the input value to be alphanumeric characters only. */
function alphaNum(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_num', inputValue, 'must be alphanumeric only')
}
/** Requires the input value to be alphabetical characters or the "-" character only. */
function alphaDash(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_dash', inputValue, 'must be alphabetical and dashes only')
}
/** Requires the input value to be alphabetical characters or the "_" character only. */
function alphaScore(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_score', inputValue, 'must be alphabetical and underscores only')
}
/** Requires the input value to be alphabetical characters, numeric characters, "-", or "_" only. */
function alphaNumDashScore(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_num_dash_score', inputValue, 'must be alphanumeric, dashes, and underscores only')
}
/** Requires the input value to be a valid RFC email address format. */
function email(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.email', inputValue, 'must be an email address')
}
/** Requires the input value to be a valid IPv4 or IPv6 address. */
function ip(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip', inputValue, 'must be a valid IP address')
}
/** Requires the input value to be a valid IPv4 address. */
function ipv4(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip.v4', inputValue, 'must be a valid IP version 4 address')
}
/** Requires the input value to be a valid IPv6 address. */
function ipv6(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip.v6', inputValue, 'must be a valid IP version 6 address')
}
/** Requires the input value to be a valid file MIME type. */
function mime(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.mime', inputValue, 'must be a valid MIME-type')
}
/** Requires the input value to be a valid RFC URL format. */
function url(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.url', inputValue, 'must be a valid URL')
}
/** Requires the input value to be a valid RFC UUID format. */
function uuid(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.uuid', inputValue, 'must be a valid UUID')
}
/**
* Builds a validation function that requires the input value to match the given regex.
* @param rex
*/
function regex(rex: RegExp): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( rex.test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'is not valid',
}
}
}
/**
* Builds a validation function that requires the input to NOT match the given regex.
* @param rex
*/
function notRegex(rex: RegExp): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( !rex.test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'is not valid',
}
}
}
/**
* Builds a validation function that requires the given input to end with the substring.
* @param substr
*/
function ends(substr: string): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).endsWith(substr) ) {
return { valid: true }
}
return {
valid: false,
message: `must end with "${substr}"`,
}
}
}
/**
* Builds a validation function that requires the given input to begin with the substring.
* @param substr
*/
function begins(substr: string): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).startsWith(substr) ) {
return { valid: true }
}
return {
valid: false,
message: `must begin with "${substr}"`,
}
}
}
/** Requires the input value to be a valid JSON string. */
function json(fieldName: string, inputValue: unknown): ValidationResult {
if ( isJSON(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be valid JSON',
}
}
/**
* Builds a validator function that requires the input value to have exactly len many characters.
* @param len
*/
function length(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length === len ) {
return { valid: true }
}
return {
valid: false,
message: `must be exactly of length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input value to have at least len many characters.
* @param len
*/
function lengthMin(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length >= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input value to have at most len many characters.
* @param len
*/
function lengthMax(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length <= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most length ${len}`,
}
}
}
export const Str = {
alpha,
alphaNum,
alphaDash,
alphaScore,
alphaNumDashScore,
email,
ip,
ipv4,
ipv6,
mime,
url,
uuid,
regex,
notRegex,
ends,
begins,
json,
length,
lengthMin,
lengthMax,
}

View File

@@ -1,85 +0,0 @@
/**
* Additional parameters passed to complex validation functions.
*/
export interface ValidatorFunctionParams {
/** The entire original input data. */
data: any,
}
/**
* An interface representing the result of an attempted validation that failed.
*/
export interface ValidationErrorResult {
/** Whether or not the validation succeeded. */
valid: false
/**
* The human-readable error message(s) describing the issue.
*/
message?: string | string[]
/**
* If true, validation of subsequent fields will stop.
*/
stopValidation?: boolean
}
/**
* An interface representing the result of an attempted validation that succeeded.
*/
export interface ValidationSuccessResult {
/** Whether or not the validation succeeded. */
valid: true
/**
* If the value was cast to a different type, or inferred, as a result of this validation,
* provide it here. It will replace the input string as the value of the field in the form.
*/
castValue?: any
/**
* If true, validation of subsequent fields will stop.
*/
stopValidation?: boolean
}
/** All possible results of an attempted validation. */
export type ValidationResult = ValidationErrorResult | ValidationSuccessResult
/** A validator function that takes only the field key and the object value. */
export type SimpleValidatorFunction = (fieldName: string, inputValue: any) => ValidationResult | Promise<ValidationResult>
/** A validator function that takes the field key, the object value, and an object of contextual params. */
export type ComplexValidatorFunction = (fieldName: string, inputValue: any, params: ValidatorFunctionParams) => ValidationResult | Promise<ValidationResult>
/** Useful type alias for all allowed validator function signatures. */
export type ValidatorFunction = SimpleValidatorFunction | ComplexValidatorFunction
/**
* A set of validation rules that are applied to input objects on validators.
*
* The keys of this object are deep-nested keys and can be used to validate
* nested properties.
*
* For example, the key "user.links.*.url" refers to the "url" property of all
* objects in the "links" array on the "user" object on:
*
* ```json
* {
* "user": {
* "links": [
* {
* "url": "..."
* },
* {
* "url": "..."
* }
* ]
* }
* }
* ```
*/
export type ValidationRules = {[key: string]: ValidatorFunction | ValidatorFunction[]}
/** A type alias denoting that a particular type has been validated. */
export type Valid<T> = T

View File

@@ -1,44 +0,0 @@
import {Template} from '../../cli'
const templateForm: Template = {
name: 'form',
fileSuffix: '.form.ts',
description: 'Create a new form request validator',
baseAppPath: ['http', 'forms'],
render(name: string) {
return `import {Injectable, FormRequest, ValidationRules, Rule} from '@extollo/lib'
/**
* ${name} object
* ----------------------------
* This is the object interface that is guaranteed when
* all of the validation rules below pass.
*/
export interface ${name}Form {
}
/**
* ${name}Request validator
* ----------------------------
* Request validator that defines the rules needed to guarantee
* that a request's input conforms to the interface defined above.
*/
@Injectable()
export class ${name}FormRequest extends FormRequest<${name}Form> {
/**
* The validation rules that should be applied to the various
* request input fields.
* @protected
*/
protected getRules(): ValidationRules {
return {
}
}
}
`
},
}
export { templateForm }

View File

@@ -1,18 +0,0 @@
import {Singleton, Inject} from '../../di'
import {CommandLine} from '../../cli'
import {templateForm} from '../templates/form'
import {Unit} from '../../lifecycle/Unit'
import {Logging} from '../../service/Logging'
@Singleton()
export class Forms extends Unit {
@Inject()
protected readonly cli!: CommandLine
@Inject()
protected readonly logging!: Logging
public async up(): Promise<void> {
this.cli.registerTemplate(templateForm)
}
}

View File

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

View File

@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
constructor(
public readonly status: HTTPStatus = 500,
public readonly message: string = '',
context?: {[key: string]: any},
) {
super('HTTP ERROR')
super('HTTP ERROR', context)
this.message = message || HTTPMessage[status]
}
}

25
src/http/RequestClass.ts Normal file
View File

@@ -0,0 +1,25 @@
import {AppClass} from '../lifecycle/AppClass'
import {Container, Inject, Injectable} from '../di'
import {RequestLocalStorage} from './RequestLocalStorage'
import {Request} from './lifecycle/Request'
/**
* Base for classes that gives access to the global application and request container.
*
* Similar to AppClass, but provides the Request instead of the Container.
*/
@Injectable()
export class RequestClass extends AppClass {
@Inject()
protected readonly requestClassStorage!: RequestLocalStorage
/** Get the request container. **/
protected container(): Container {
return this.requestClassStorage.get()
}
/** Get the request. */
protected request(): Request {
return this.requestClassStorage.get()
}
}

View File

@@ -0,0 +1,32 @@
import { AsyncLocalStorage } from 'async_hooks'
import {Request} from './lifecycle/Request'
import {Singleton} from '../di'
import {ErrorWithContext} from '../util'
export class InvalidOutOfRequestAccessError extends ErrorWithContext {
constructor() {
super(`Attempted to access request via local storage outside of async lifecycle!`)
}
}
@Singleton()
export class RequestLocalStorage {
protected readonly store: AsyncLocalStorage<Request> = new AsyncLocalStorage<Request>()
get(): Request {
const req = this.store.getStore()
if ( !req ) {
throw new InvalidOutOfRequestAccessError()
}
return req
}
has(): boolean {
return Boolean(this.store.getStore())
}
run<T>(req: Request, closure: () => T): T {
return this.store.run(req, closure)
}
}

View File

@@ -5,6 +5,7 @@ import {Logging} from '../../service/Logging'
import {AppClass} from '../../lifecycle/AppClass'
import {Request} from '../lifecycle/Request'
import {error} from '../response/ErrorResponseFactory'
import {HTTPError} from '../HTTPError'
/**
* Interface for fluently registering kernel modules into the kernel.
@@ -105,7 +106,8 @@ export class HTTPKernel extends AppClass {
}
} catch (e: any) {
this.logging.error(e)
await error(e).status(HTTPStatus.INTERNAL_SERVER_ERROR)
const status = (e instanceof HTTPError && e.status) ? e.status : HTTPStatus.INTERNAL_SERVER_ERROR
await error(e).status(status)
.write(request)
}

View File

@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
import {plaintext} from '../../response/StringResponseFactory'
import {ResponseFactory} from '../../response/ResponseFactory'
import {json} from '../../response/JSONResponseFactory'
import {UniversalPath} from '../../../util'
import {file} from '../../response/FileResponseFactory'
/**
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
if ( object instanceof ResponseFactory ) {
await object.write(request)
} else if ( object instanceof UniversalPath ) {
await file(object).write(request)
} else if ( typeof object !== 'undefined' ) {
await json(object).write(request)
} else {

View File

@@ -0,0 +1,28 @@
import {HTTPKernelModule} from '../HTTPKernelModule'
import {Inject, Injectable} from '../../../di'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {Bus} from '../../../support/bus'
/**
* HTTP kernel module that creates a request-specific event bus
* and injects it into the request container.
*/
@Injectable()
export class ClearRequestEventBusHTTPModule extends HTTPKernelModule {
@Inject()
protected bus!: Bus
public static register(kernel: HTTPKernel): void {
kernel.register(this).first()
}
public async apply(request: Request): Promise<Request> {
const requestBus = request.make<Bus>(Bus)
await requestBus.down()
// FIXME disconnect request bus from global event bus
return request
}
}

View File

@@ -1,9 +1,8 @@
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {ActivatedRoute} from '../../routing/ActivatedRoute'
import {ResponseObject} from '../../routing/Route'
import {http} from '../../response/HTTPErrorResponseFactory'
import {HTTPStatus} from '../../../util'
import {HTTPStatus, withErrorContext} from '../../../util'
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
/**
@@ -18,10 +17,21 @@ export class ExecuteResolvedRouteHandlerHTTPModule extends AbstractResolvedRoute
public async apply(request: Request): Promise<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const object: ResponseObject = await route.handler(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 this.applyResponseObject(object, request)
await withErrorContext(async () => {
const result = await route.handler
.tap(handler => handler(...params))
.apply(request)
await this.applyResponseObject(result, request)
}, {
route,
})
} else {
await http(HTTPStatus.NOT_FOUND).write(request)
request.response.blockingWriteback(true)

View File

@@ -17,7 +17,7 @@ export class ExecuteResolvedRoutePostflightHTTPModule extends AbstractResolvedRo
public async apply(request: Request): Promise<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const postflight = route.postflight
for ( const handler of postflight ) {

View File

@@ -4,6 +4,7 @@ import {Request} from '../../lifecycle/Request'
import {ActivatedRoute} from '../../routing/ActivatedRoute'
import {ResponseObject} from '../../routing/Route'
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
/**
* HTTP Kernel module that executes the preflight handlers for the route.
@@ -17,16 +18,28 @@ export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRou
public async apply(request: Request): Promise<Request> {
if ( request.hasInstance(ActivatedRoute) ) {
const route = <ActivatedRoute> request.make(ActivatedRoute)
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const preflight = route.preflight
for ( const handler of preflight ) {
const result: ResponseObject = await handler(request)
if ( typeof result !== 'undefined' ) {
await this.applyResponseObject(result, request)
request.response.blockingWriteback(true)
}
await withErrorContext(async () => {
const result: ResponseObject = await handler(request)
if ( typeof result !== 'undefined' ) {
await this.applyResponseObject(result, request)
request.response.blockingWriteback(true)
}
}, { handler })
}
const parameters = route.parameters
const resolveResult = await collect(parameters)
.asyncMapRight(handler => handler(request))
if ( isLeft(resolveResult) ) {
return unleft(resolveResult)
}
route.resolvedParameters = unright(resolveResult).toArray()
}
return request

View File

@@ -0,0 +1,28 @@
import {HTTPKernelModule} from '../HTTPKernelModule'
import {Inject, Injectable} from '../../../di'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {Bus} from '../../../support/bus'
/**
* HTTP kernel module that creates a request-specific event bus
* and injects it into the request container.
*/
@Injectable()
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
@Inject()
protected bus!: Bus
public static register(kernel: HTTPKernel): void {
kernel.register(this).first()
}
public async apply(request: Request): Promise<Request> {
const requestBus = this.container().makeNew<Bus>(Bus)
await requestBus.up(false)
await requestBus.connect(this.bus)
request.purge(Bus).registerProducer(Bus, () => requestBus)
return request
}
}

View File

@@ -28,8 +28,8 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
const route = this.routing.match(request.method, request.path)
if ( route ) {
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
const activated = new ActivatedRoute(route, request.path)
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
} else {
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)
}

View File

@@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
})
busboy.on('finish', () => {
this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`)
res()
})

View File

@@ -21,6 +21,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule {
public async apply(request: Request): Promise<Request> {
if ( !this.config.get('server.poweredBy.hide', false) ) {
request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo'))
request.response.setHeader('Server', this.config.get('server.poweredBy.header', 'Extollo'))
}
return request

View File

@@ -1,11 +1,13 @@
import {Injectable, ScopedContainer, Container} from '../../di'
import {infer, UniversalPath} from '../../util'
import {Container, Injectable, ScopedContainer} from '../../di'
import {HTTPStatus, infer, Pipeline, Safe, UniversalPath} from '../../util'
import {IncomingMessage, ServerResponse} from 'http'
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
import {TLSSocket} from 'tls'
import * as url from 'url'
import {Response} from './Response'
import * as Negotiator from 'negotiator'
import {HTTPError} from '../HTTPError'
import {ActivatedRoute} from '../routing/ActivatedRoute'
/**
* Enumeration of different HTTP verbs.
@@ -108,6 +110,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 +127,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 ?? '/'
@@ -181,23 +178,41 @@ export class Request extends ScopedContainer implements DataContainer {
* @param key
*/
public input(key?: string): unknown {
if ( !key ) {
return {
...this.parsedInput,
...this.query,
...this.uploadedFiles,
let sources = {
...this.parsedInput,
...this.query,
...this.uploadedFiles,
}
if ( this.hasKey(ActivatedRoute) ) {
sources = {
...sources,
...this.make<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute).params,
}
}
if ( key in this.parsedInput ) {
return this.parsedInput[key]
if ( !key ) {
return sources
}
if ( key in this.query ) {
return this.query[key]
if ( key in sources ) {
return sources[key]
}
}
/**
* Look up a field from the request and wrap it in a safe-value accessor.
* @param key
*/
public safe(key?: string): Safe {
return Pipeline.id()
.tap(val => new Safe(val))
.tap(safe => safe.onError(message => {
throw new HTTPError(HTTPStatus.BAD_REQUEST, `Invalid field (${key}): ${message}`)
}))
.apply(this.input(key))
}
/**
* Get the UniversalPath instance for a file uploaded in the given field on the request.
*/

View File

@@ -2,6 +2,8 @@ import {Request} from './Request'
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
import {ServerResponse} from 'http'
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
import {Readable} from 'stream'
import {Logging} from '../../service/Logging'
/**
* Error thrown when the server tries to re-send headers after they have been sent once.
@@ -47,7 +49,7 @@ export class Response {
private isBlockingWriteback = false
/** The body contents that should be written to the response. */
public body = ''
public body: string | Buffer | Uint8Array | Readable = ''
/**
* Behavior subject fired right before the response content is written.
@@ -67,6 +69,10 @@ export class Response {
protected readonly serverResponse: ServerResponse,
) { }
protected get logging(): Logging {
return this.request.make(Logging)
}
/** Get the currently set response status. */
public getStatus(): HTTPStatus {
return this.status
@@ -94,6 +100,7 @@ export class Response {
/** Set the value of the response header. */
public setHeader(name: string, value: string | string[]): this {
this.logging.verbose(`Will set header on response: ${name}`)
if ( this.sentHeaders ) {
throw new HeadersAlreadySentError(this, name)
}
@@ -107,6 +114,7 @@ export class Response {
* @param data
*/
public setHeaders(data: {[name: string]: string | string[]}): this {
this.logging.verbose(`Will set headers on response: ${Object.keys(data).join(', ')}`)
if ( this.sentHeaders ) {
throw new HeadersAlreadySentError(this)
}
@@ -122,12 +130,16 @@ export class Response {
* @param value
*/
public appendHeader(name: string, value: string | string[]): this {
this.logging.verbose(`Will append header: ${name}`)
if ( this.sentHeaders ) {
throw new HeadersAlreadySentError(this, name)
}
if ( !Array.isArray(value) ) {
value = [value]
}
let existing = this.headers[name] ?? []
if ( !Array.isArray(existing) ) {
existing = [existing]
@@ -146,6 +158,7 @@ export class Response {
* Write the headers to the client.
*/
public sendHeaders(): this {
this.logging.verbose(`Sending headers...`)
const headers = {} as any
const setCookieHeaders = this.cookies.getSetCookieHeaders()
@@ -192,18 +205,34 @@ export class Response {
* Write the headers and specified data to the client.
* @param data
*/
public async write(data: unknown): Promise<void> {
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
this.logging.verbose(`Writing headers & data to response... (destroyed? ${this.serverResponse.destroyed})`)
return new Promise<void>((res, rej) => {
if ( this.responseEnded || this.serverResponse.destroyed ) {
throw new ErrorWithContext('Tried to write to Response after lifecycle ended.')
}
if ( !this.sentHeaders ) {
this.sendHeaders()
}
this.serverResponse.write(data, error => {
if ( error ) {
rej(error)
} else {
res()
}
})
if ( data instanceof Readable ) {
data.pipe(this.serverResponse)
.on('finish', () => {
res()
})
.on('error', error => {
rej(error)
})
} else {
this.serverResponse.write(data, error => {
if ( error ) {
rej(error)
} else {
res()
}
})
}
})
}
@@ -212,12 +241,28 @@ export class Response {
*/
public async send(): Promise<void> {
await this.sending$.next(this)
this.setHeader('Content-Length', String(this.body?.length ?? 0))
if ( !(this.body instanceof Readable) ) {
this.setHeader('Content-Length', String(Buffer.from(this.body ?? '').length))
}
this.setHeader('Date', (new Date()).toUTCString())
this.setHeader('Permissions-Policy', 'interest-cohort=()')
await this.write(this.body ?? '')
this.end()
await this.sent$.next(this)
}
/**
* Returns true if the response can still be sent. False if it has been sent
* or the connection has been destroyed.
*/
public canSend(): boolean {
return !(this.responseEnded || this.serverResponse.destroyed)
}
/**
* Mark the response as ended and close the socket.
*/

View File

@@ -72,9 +72,12 @@ export class ErrorResponseFactory extends ResponseFactory {
}
}
const suggestion = this.getSuggestion()
let str = `
<b>Sorry, an unexpected error occurred while processing your request.</b>
<br>
${suggestion ? '<br><b>Suggestion:</b> ' + suggestion + '<br>' : ''}
<pre><code>
Name: ${thrownError.name}
Message: ${thrownError.message}
@@ -88,7 +91,7 @@ Stack trace:
str += `
<pre><code>
Context:
${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
${Object.keys(context).map(key => ` - ${key} : ${JSON.stringify(context[key]).replace(/\n/g, '<br>')}`)
.join('\n')}
</code></pre>
`
@@ -100,4 +103,12 @@ ${Object.keys(context).map(key => ` - ${key} : ${context[key]}`)
protected buildJSON(thrownError: Error): string {
return JSON.stringify(api.error(thrownError))
}
protected getSuggestion(): string {
if ( this.thrownError.message.startsWith('No such dependency is registered with this container: class SecurityContext') ) {
return 'It looks like this route relies on the security framework. Is the route you are accessing inside a middleware (e.g. SessionAuthMiddleware)?'
}
return ''
}
}

View File

@@ -0,0 +1,38 @@
import {ResponseFactory} from './ResponseFactory'
import {Request} from '../lifecycle/Request'
import {ErrorWithContext, UniversalPath} from '../../util'
import {Logging} from '../../service/Logging'
/**
* Helper function that creates a FileResponseFactory for the given path.
* @param path
*/
export function file(path: UniversalPath): FileResponseFactory {
return new FileResponseFactory(path)
}
/**
* HTTP response factory that sends a file referenced by a given UniversalPath.
*/
export class FileResponseFactory extends ResponseFactory {
constructor(
/** The file to be sent. */
public readonly path: UniversalPath,
) {
super()
}
public async write(request: Request): Promise<Request> {
if ( !(await this.path.isFile()) ) {
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
path: this.path,
})
}
request.make<Logging>(Logging).debug(`Setting Content-Type of ${this.path} to ${this.path.contentType}...`)
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
request.response.body = await this.path.readStream()
return request
}
}

View File

@@ -0,0 +1,31 @@
import {ResponseFactory} from './ResponseFactory'
import {HTTPStatus} from '../../util'
import {Request} from '../lifecycle/Request'
/**
* Helper function to create a new RedirectResponseFactory to the given destination.
* @param destination
*/
export function redirect(destination: string|URL): RedirectResponseFactory {
return new RedirectResponseFactory(destination instanceof URL ? destination.toString() : destination)
}
/**
* Response factory that sends an HTTP redirect to the given destination.
*/
export class RedirectResponseFactory extends ResponseFactory {
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
constructor(
/** THe URL where the client should redirect to. */
public readonly destination: string,
) {
super()
}
public async write(request: Request): Promise<Request> {
request = await super.write(request)
request.response.setHeader('Location', this.destination)
return request
}
}

View File

@@ -0,0 +1,40 @@
import {ResponseFactory} from './ResponseFactory'
import {HTTPStatus} from '../../util'
import {Request} from '../lifecycle/Request'
import {Routing} from '../../service/Routing'
/**
* Helper function to create a new RouteResponseFactory to the given destination.
* @param nameOrPath
*/
export function route(nameOrPath: string): RouteResponseFactory {
return new RouteResponseFactory(nameOrPath)
}
/**
* Response factory that sends an HTTP redirect to the given destination.
*/
export class RouteResponseFactory extends ResponseFactory {
protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY
constructor(
/** The alias or path of the route to redirect to. */
public readonly nameOrPath: string,
) {
super()
}
public async write(request: Request): Promise<Request> {
const routing = <Routing> request.make(Routing)
request = await super.write(request)
try {
const routePath = routing.getNamedPath(this.nameOrPath)
request.response.setHeader('Location', routePath.toRemote)
} catch (e: unknown) {
request.response.setHeader('Location', routing.getAppUrl().concat(this.nameOrPath).toRemote)
}
return request
}
}

View File

@@ -6,7 +6,7 @@ import {Request} from '../lifecycle/Request'
* Helper function to create a new TemporaryRedirectResponseFactory to the given destination.
* @param destination
*/
export function redirect(destination: string): TemporaryRedirectResponseFactory {
export function temporary(destination: string): TemporaryRedirectResponseFactory {
return new TemporaryRedirectResponseFactory(destination)
}

View File

@@ -13,6 +13,15 @@ export function view(name: string, data?: {[key: string]: any}): ViewResponseFac
return new ViewResponseFactory(name, data)
}
/**
* Helper function that creates a new ViewResponseFactory that redirects the user to
* the given URL.
* @param url
*/
export function redirectToGet(url: string): ViewResponseFactory {
return view('@extollo:redirect', { redirectUrl: url })
}
/**
* HTTP response factory that uses the ViewEngine service to render a view
* and send it as HTML.

View File

@@ -1,10 +1,10 @@
/**
* Base type for an API response format.
*/
export interface APIResponse {
export interface APIResponse<T> {
success: boolean,
message?: string,
data?: any,
data?: T,
error?: {
name: string,
message: string,
@@ -17,7 +17,7 @@ export interface APIResponse {
* @param {string} displayMessage
* @return APIResponse
*/
export function message(displayMessage: string): APIResponse {
export function message(displayMessage: string): APIResponse<void> {
return {
success: true,
message: displayMessage,
@@ -29,7 +29,7 @@ export function message(displayMessage: string): APIResponse {
* @param record
* @return APIResponse
*/
export function one(record: unknown): APIResponse {
export function one<T>(record: T): APIResponse<T> {
return {
success: true,
data: record,
@@ -41,7 +41,7 @@ export function one(record: unknown): APIResponse {
* @param {array} records
* @return APIResponse
*/
export function many(records: any[]): APIResponse {
export function many<T>(records: T[]): APIResponse<{records: T[], total: number}> {
return {
success: true,
data: {
@@ -56,7 +56,7 @@ export function many(records: any[]): APIResponse {
* @return APIResponse
* @param thrownError
*/
export function error(thrownError: string | Error): APIResponse {
export function error(thrownError: string | Error): APIResponse<void> {
if ( typeof thrownError === 'string' ) {
return {
success: false,

View File

@@ -1,10 +1,16 @@
import {ErrorWithContext} from '../../util'
import {ResolvedRouteHandler, Route} from './Route'
import {ParameterProvidingMiddleware, ResolvedRouteHandler, Route} from './Route'
import {Constructable, Injectable} from '../../di'
export type HandlerParamProviders<THandlerParams extends unknown[]> = {
[Index in keyof THandlerParams]: ParameterProvidingMiddleware<THandlerParams[Index]>
} & {length: THandlerParams['length']}
/**
* Class representing a resolved route that a request is mounted to.
*/
export class ActivatedRoute {
@Injectable()
export class ActivatedRoute<TReturn, THandlerParams extends unknown[]> {
/**
* The parsed params from the route definition.
*
@@ -25,7 +31,7 @@ export class ActivatedRoute {
/**
* The resolved function that should handle the request for this route.
*/
public readonly handler: ResolvedRouteHandler
public readonly handler: Constructable<(...x: THandlerParams) => TReturn>
/**
* Pre-middleware that should be applied to the request on this route.
@@ -37,9 +43,13 @@ export class ActivatedRoute {
*/
public readonly postflight: ResolvedRouteHandler[]
public readonly parameters: HandlerParamProviders<THandlerParams>
public resolvedParameters?: THandlerParams
constructor(
/** The route this ActivatedRoute refers to. */
public readonly route: Route,
public readonly route: Route<TReturn, THandlerParams>,
/** The request path that activated that route. */
public readonly path: string,
@@ -54,9 +64,17 @@ export class ActivatedRoute {
throw error
}
if ( !route.handler ) {
throw new ErrorWithContext('Cannot instantiate ActivatedRoute. Matched route is not handled.', {
matchedRoute: String(route),
requestPath: path,
})
}
this.params = params
this.preflight = route.resolvePreflight()
this.handler = route.resolveHandler()
this.postflight = route.resolvePostflight()
this.preflight = route.getPreflight().toArray()
this.handler = route.handler
this.postflight = route.getPostflight().toArray()
this.parameters = route.getParameters().toArray() as HandlerParamProviders<THandlerParams>
}
}

View File

@@ -1,17 +1,20 @@
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,
) {
super()
if ( !request ) {
throw new Error('Middleware constructed without request')
}
}
protected container(): Container {

Some files were not shown because too many files have changed in this diff Show More