Compare commits
171 Commits
Author | SHA1 | Date | |
---|---|---|---|
ac6fd0ef1d | |||
9a55623370 | |||
743e81ae94 | |||
61c4d86fff | |||
9aa3f56340 | |||
899c8448fc | |||
7c9b1ff212 | |||
cae9a1acbe | |||
aeb37d711b | |||
d66ae85f54 | |||
68b81106e6 | |||
5395fb9054 | |||
bbafd54dcc | |||
37fcaabdef | |||
bbf2807cfa | |||
2d9f22b895 | |||
0484a586bd | |||
52762bd4a1 | |||
c0595f3ef9 | |||
5557aae543 | |||
f1791b1d76 | |||
a173393697 | |||
c966904418 | |||
f63891ef99 | |||
3d836afa59 | |||
9b47d2ac99 | |||
085fe04f90 | |||
e339ec718d | |||
efb9726470 | |||
8a153e3807 | |||
b8cf8499d2 | |||
710b6cb535 | |||
91d76f44b5 | |||
8774bd8d34 | |||
3712fae979 | |||
4aa33e8dd2 | |||
ef405093dc | |||
fc85c9d2c8 | |||
d00e6a02e2 | |||
ea81a37315 | |||
33a64b99ff | |||
dc663ec8f5 | |||
6476416c67 | |||
4d7769de56 | |||
6ca4bc1151 | |||
7d3fde85eb | |||
111ce0bcf9 | |||
d3a6a8495c | |||
2ff9354538 | |||
cf0ae260dc | |||
fe9170282f | |||
2e43b5bda9 | |||
705bb20db1 | |||
1be73dd347 | |||
afbf6e7682 | |||
48ce1bfa2f | |||
1d717e0eb9 | |||
90b16eef53 | |||
1399399af9 | |||
30a23b1659 | |||
de13030815 | |||
fd77ad5cd3 | |||
814a5763d9 | |||
9ede67cb12 | |||
8a9264b9de | |||
d210cba236 | |||
015d6fd6ae | |||
ce4133ff8e | |||
9d8f43d8fb | |||
940d50b89c | |||
36647a013d | |||
5616b3cc1f | |||
2e7c927114 | |||
8b2ee1c949 | |||
c7557cf5b6 | |||
771fed8002 | |||
7914a8f12e | |||
5fa4f614e2 | |||
ee21811771 | |||
8b9f393405 | |||
f6a7cac05c | |||
25265b5560 | |||
a779ec1d09 | |||
bea48602f5 | |||
445f16d973 | |||
351a2e14b8 | |||
b42e91533a | |||
78cb26fcb2 | |||
514a578260 | |||
3d7d583367 | |||
6f66126d38 | |||
10b3e1ecc3 | |||
795adac68b | |||
ca348b2ff6 | |||
508d92f759 | |||
a590d78155 | |||
dbe48ea8a5 | |||
467721f775 | |||
153f8f7685 | |||
ba87ea32c3 | |||
737d06f6f0 | |||
6ee3e2a729 | |||
1288e51de0 | |||
1fde692a65 | |||
cdecb7e628 | |||
8f08b94f74 | |||
a039b1ff25 | |||
70d67c2730 | |||
0774deea91 | |||
16e5fa00aa | |||
e098a5edb7 | |||
6d1cf18680 | |||
506fb55c74 | |||
cfd555723b | |||
32050cb2ce | |||
dc16dfdb81 | |||
8cf19792a6 | |||
9b8333295f | |||
5ffb91329e | |||
b105a61ca2 | |||
9204a02450 | |||
463076d182 | |||
b5eb407b55 | |||
0ed096c782 | |||
5175d64e36 | |||
bd7d6a2dbd | |||
d251f8bc15 | |||
bf4a675faa | |||
6fc901b3ec | |||
50e0cf3090 | |||
d245d15ad6 | |||
265837b5cd | |||
fe0b4d6d8f | |||
ce1d22ff44 | |||
b7bfb3e153 | |||
e57819d318 | |||
0a9dd30909 | |||
d92c8b5409 | |||
589cb7d579 | |||
3680ad1914 | |||
96e13d85fc | |||
5a9283ad85 | |||
b1ea489ccb | |||
c3f2779650 | |||
248b24e612 | |||
b4a9057e2b | |||
c078d695a8 | |||
55ffadc742 | |||
56574d43ce | |||
e16f02ce12 | |||
c34fad3502 | |||
156006053b | |||
22cf6aa953 | |||
b35eb8d6a1 | |||
9ee4c42e43 | |||
8d1dcc87fb | |||
3efbfecf9d | |||
a1d04d652e | |||
5940b6e2b3 | |||
074a3187eb | |||
26e0444e40 | |||
fcce28081b | |||
e86cf420df | |||
e33d8dee8f | |||
39d97d6e14 | |||
f496046461 | |||
b3b5b169e8 | |||
5d960e6186 | |||
cf6d14abca | |||
faa8a31102 | |||
7506d6567d |
252
.drone.yml
252
.drone.yml
@ -1,219 +1,87 @@
|
|||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: docs
|
|
||||||
steps:
|
|
||||||
# ============ BUILD STEPS ===============
|
|
||||||
- name: build documentation
|
|
||||||
image: glmdev/node-pnpm:latest
|
|
||||||
commands:
|
|
||||||
- pnpm i --silent
|
|
||||||
- pnpm docs:build
|
|
||||||
- cd docs && tar czf ../extollo_api_documentation.tar.gz www
|
|
||||||
|
|
||||||
# =============== DEPLOY STEPS ===============
|
|
||||||
- name: copy artifacts to static host
|
|
||||||
image: appleboy/drone-scp
|
|
||||||
settings:
|
|
||||||
host:
|
|
||||||
from_secret: docs_deploy_host
|
|
||||||
username:
|
|
||||||
from_secret: docs_deploy_user
|
|
||||||
key:
|
|
||||||
from_secret: docs_deploy_key
|
|
||||||
port: 22
|
|
||||||
source: extollo_api_documentation.tar.gz
|
|
||||||
target: /var/nfs/general/static/sites/extollo
|
|
||||||
when:
|
|
||||||
event: promote
|
|
||||||
target: docs
|
|
||||||
|
|
||||||
- name: deploy artifacts on static host
|
|
||||||
image: appleboy/drone-ssh
|
|
||||||
settings:
|
|
||||||
host:
|
|
||||||
from_secret: docs_deploy_host
|
|
||||||
username:
|
|
||||||
from_secret: docs_deploy_user
|
|
||||||
key:
|
|
||||||
from_secret: docs_deploy_key
|
|
||||||
port: 22
|
|
||||||
script:
|
|
||||||
- cd /var/nfs/general/static/sites/extollo
|
|
||||||
- rm -rf docs
|
|
||||||
- tar xzf extollo_api_documentation.tar.gz
|
|
||||||
- rm -rf extollo_api_documentation.tar.gz
|
|
||||||
- mv www docs
|
|
||||||
when:
|
|
||||||
event: promote
|
|
||||||
target: docs
|
|
||||||
|
|
||||||
# =============== BUILD NOTIFICATIONS ===============
|
|
||||||
- name: send build success notifications
|
|
||||||
image: plugins/webhook
|
|
||||||
settings:
|
|
||||||
urls:
|
|
||||||
from_secret: notify_webhook_url
|
|
||||||
content_type: application/json
|
|
||||||
template: |
|
|
||||||
{
|
|
||||||
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
|
||||||
"message": "Build & deploy completed successfully.",
|
|
||||||
"priority": 4
|
|
||||||
}
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event:
|
|
||||||
- promote
|
|
||||||
|
|
||||||
- name: send build error notifications
|
|
||||||
image: plugins/webhook
|
|
||||||
settings:
|
|
||||||
urls:
|
|
||||||
from_secret: notify_webhook_url
|
|
||||||
content_type: application/json
|
|
||||||
template: |
|
|
||||||
{
|
|
||||||
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
|
||||||
"message": "Documentation build failed!",
|
|
||||||
"priority": 6
|
|
||||||
}
|
|
||||||
when:
|
|
||||||
status: failure
|
|
||||||
---
|
---
|
||||||
|
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: default
|
type: kubernetes
|
||||||
type: docker
|
name: docs
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
pod-security.kubernetes.io/audit: privileged
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: docker daemon
|
||||||
|
image: docker:dind
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: post build in progress comment to PR
|
- name: typedoc build
|
||||||
image: tsakidev/giteacomment:latest
|
image: node:18
|
||||||
settings:
|
|
||||||
gitea_token:
|
|
||||||
from_secret: gitea_token
|
|
||||||
gitea_base_url: https://code.garrettmills.dev
|
|
||||||
comment: "Build ${DRONE_BUILD_NUMBER} started."
|
|
||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
- name: remove lockfile
|
|
||||||
image: glmdev/node-pnpm:latest
|
|
||||||
commands:
|
commands:
|
||||||
- rm -rf pnpm-lock.yaml
|
- "node -v"
|
||||||
when:
|
- "npm add --global pnpm"
|
||||||
event:
|
- "pnpm --version"
|
||||||
exclude: tag
|
- pnpm i
|
||||||
|
- pnpm run docs:build
|
||||||
|
|
||||||
- name: build module
|
- name: container build
|
||||||
image: glmdev/node-pnpm:latest
|
image: docker:latest
|
||||||
|
privileged: true
|
||||||
commands:
|
commands:
|
||||||
|
- "while ! docker stats --no-stream; do sleep 1; done"
|
||||||
|
- docker image build docs -t $DOCKER_REGISTRY/extollo/docs:latest
|
||||||
|
- docker push $DOCKER_REGISTRY/extollo/docs:latest
|
||||||
|
environment:
|
||||||
|
DOCKER_HOST: tcp://localhost:2375
|
||||||
|
DOCKER_REGISTRY:
|
||||||
|
from_secret: DOCKER_REGISTRY
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
status: success
|
||||||
|
|
||||||
|
- name: k8s rollout
|
||||||
|
image: bitnami/kubectl
|
||||||
|
commands:
|
||||||
|
- cd docs/deploy && kubectl apply -f .
|
||||||
|
- kubectl rollout restart -n extollo deployment/docs
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
status: success
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: npm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: node.js build
|
||||||
|
image: node:18
|
||||||
|
commands:
|
||||||
|
- "npm add --global pnpm"
|
||||||
- pnpm i
|
- pnpm i
|
||||||
- pnpm build
|
- pnpm build
|
||||||
- mkdir artifacts
|
|
||||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
|
||||||
|
|
||||||
- name: create Gitea release
|
- name: gitea release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
settings:
|
settings:
|
||||||
api_key:
|
api_key:
|
||||||
from_secret: gitea_token
|
from_secret: GITEA_TOKEN
|
||||||
base_url: https://code.garrettmills.dev
|
base_url: https://code.garrettmills.dev
|
||||||
checksum: md5
|
checksum: md5
|
||||||
title: ${DRONE_TAG}
|
title: ${DRONE_TAG}
|
||||||
files: "artifacts/*"
|
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
status: success
|
status: success
|
||||||
|
|
||||||
- name: prepare NPM release
|
- name: npm release
|
||||||
image: glmdev/node-pnpm:latest
|
|
||||||
commands:
|
|
||||||
- rm -rf artifacts
|
|
||||||
when:
|
|
||||||
event: tag
|
|
||||||
status: success
|
|
||||||
|
|
||||||
- name: create NPM release
|
|
||||||
image: plugins/npm
|
image: plugins/npm
|
||||||
settings:
|
settings:
|
||||||
username: extollo_bot
|
username: extollo_bot
|
||||||
password:
|
password:
|
||||||
from_secret: npm_password
|
from_secret: NPM_PASSWORD
|
||||||
email: extollo@garrettmills.dev
|
email: extollo@garrettmills.dev
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
status: success
|
status: success
|
||||||
|
|
||||||
- name: send build success notifications
|
|
||||||
image: plugins/webhook
|
|
||||||
settings:
|
|
||||||
urls:
|
|
||||||
from_secret: notify_webhook_url
|
|
||||||
content_type: application/json
|
|
||||||
template: |
|
|
||||||
{
|
|
||||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
|
||||||
"message": "Build completed successfully.",
|
|
||||||
"priority": 4
|
|
||||||
}
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event:
|
|
||||||
exclude:
|
|
||||||
- pull_request
|
|
||||||
- tag
|
|
||||||
|
|
||||||
- name: send publish success notifications
|
|
||||||
image: plugins/webhook
|
|
||||||
settings:
|
|
||||||
urls:
|
|
||||||
from_secret: notify_webhook_url
|
|
||||||
content_type: application/json
|
|
||||||
template: |
|
|
||||||
{
|
|
||||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
|
||||||
"message": "Successfully published tag ${DRONE_TAG}.",
|
|
||||||
"priority": 4
|
|
||||||
}
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event: tag
|
|
||||||
|
|
||||||
- name: post build success comment to PR
|
|
||||||
image: tsakidev/giteacomment:latest
|
|
||||||
settings:
|
|
||||||
gitea_token:
|
|
||||||
from_secret: gitea_token
|
|
||||||
gitea_base_url: https://code.garrettmills.dev
|
|
||||||
comment: "Build ${DRONE_BUILD_NUMBER} completed successfully."
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
- name: send build error notifications
|
|
||||||
image: plugins/webhook
|
|
||||||
settings:
|
|
||||||
urls:
|
|
||||||
from_secret: notify_webhook_url
|
|
||||||
content_type: application/json
|
|
||||||
template: |
|
|
||||||
{
|
|
||||||
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
|
|
||||||
"message": "Build failed!",
|
|
||||||
"priority": 6
|
|
||||||
}
|
|
||||||
when:
|
|
||||||
status: failure
|
|
||||||
event:
|
|
||||||
exclude:
|
|
||||||
- pull_request
|
|
||||||
|
|
||||||
- name: post build error comment to PR
|
|
||||||
image: tsakidev/giteacomment:latest
|
|
||||||
settings:
|
|
||||||
gitea_token:
|
|
||||||
from_secret: gitea_token
|
|
||||||
gitea_base_url: https://code.garrettmills.dev
|
|
||||||
comment: "Build ${DRONE_BUILD_NUMBER} failed!"
|
|
||||||
when:
|
|
||||||
status: failure
|
|
||||||
event: pull_request
|
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
.undodir
|
||||||
|
|
||||||
# ---> JetBrains
|
# ---> JetBrains
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
55
.idea/codeStyles/Project.xml
Normal file
55
.idea/codeStyles/Project.xml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<editorconfig>
|
||||||
|
<option name="ENABLED" value="false" />
|
||||||
|
</editorconfig>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="PHP">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
<option name="SMART_TABS" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Shell Script">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="4" />
|
||||||
|
<option name="TAB_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
19
.idea/dataSources.xml
Normal file
19
.idea/dataSources.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||||
|
<driver-ref>mongo</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||||
|
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -4,5 +4,6 @@
|
|||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$" />
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="module" module-name="extollo" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
4
docs/Dockerfile
Normal file
4
docs/Dockerfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
FROM joseluisq/static-web-server:2
|
||||||
|
|
||||||
|
COPY ./www /public
|
||||||
|
|
@ -22,7 +22,7 @@ Node.js provides an excellent platform for quickly getting an application up and
|
|||||||
## Getting Started
|
## Getting Started
|
||||||
Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel.
|
Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel.
|
||||||
|
|
||||||
Check out the [Getting Started](https://extollo.garrettmills.dev/pages/Documentation/Getting-Started.html) page site for more information.
|
Check out the [Getting Started](pages/Getting-Started.html) page site for more information.
|
||||||
|
|
||||||
## License & Philosophy
|
## License & Philosophy
|
||||||
The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information.
|
The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information.
|
||||||
|
5
docs/deploy/00-namespace.yaml
Normal file
5
docs/deploy/00-namespace.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: extollo
|
23
docs/deploy/10-deployment.yaml
Normal file
23
docs/deploy/10-deployment.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: docs
|
||||||
|
namespace: extollo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: docs
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
name: docs
|
||||||
|
namespace: extollo
|
||||||
|
labels:
|
||||||
|
app: docs
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: docs-www
|
||||||
|
image: registry.millslan.net/extollo/docs
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
25
docs/deploy/15-service.yaml
Normal file
25
docs/deploy/15-service.yaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: docs-service
|
||||||
|
namespace: extollo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: docs
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: docs-service-lb
|
||||||
|
namespace: extollo
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
selector:
|
||||||
|
app: docs
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
13
docs/deploy/20-certificate.yaml
Normal file
13
docs/deploy/20-certificate.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: docs-tls
|
||||||
|
namespace: extollo
|
||||||
|
spec:
|
||||||
|
secretName: docs-tls-secret
|
||||||
|
dnsNames:
|
||||||
|
- 'extollo.garrettmills.dev'
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-ca
|
||||||
|
kind: ClusterIssuer
|
25
docs/deploy/20-ingress.yaml
Normal file
25
docs/deploy/20-ingress.yaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: docs-ingress
|
||||||
|
namespace: extollo
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- extollo.garrettmills.dev
|
||||||
|
secretName: docs-tls-secret
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: extollo.garrettmills.dev
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- pathType: Prefix
|
||||||
|
path: '/'
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: docs-service
|
||||||
|
port:
|
||||||
|
number: 80
|
@ -1 +0,0 @@
|
|||||||
# About the Extollo Project
|
|
@ -1,4 +1,3 @@
|
|||||||
# Getting Started with Extollo
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
1608
package-lock.json
generated
1608
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@extollo/lib",
|
"name": "@extollo/lib",
|
||||||
"version": "0.3.1",
|
"version": "0.14.14",
|
||||||
"description": "The framework library that lifts up your code.",
|
"description": "The framework library that lifts up your code.",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
@ -8,45 +8,56 @@
|
|||||||
"lib": "lib"
|
"lib": "lib"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atao60/fse-cli": "^0.1.7",
|
||||||
|
"@extollo/ui": "^0.1.0",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/busboy": "^0.2.3",
|
"@types/busboy": "^0.2.4",
|
||||||
"@types/cli-table": "^0.3.0",
|
"@types/cli-table": "^0.3.1",
|
||||||
"@types/mkdirp": "^1.0.1",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"@types/jsonwebtoken": "^8.5.9",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/mkdirp": "^1.0.2",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.18.51",
|
||||||
"@types/pg": "^8.6.0",
|
"@types/pg": "^8.10.2",
|
||||||
"@types/pluralize": "^0.0.29",
|
"@types/pluralize": "^0.0.29",
|
||||||
"@types/pug": "^2.0.4",
|
"@types/pug": "^2.0.6",
|
||||||
"@types/rimraf": "^3.0.0",
|
"@types/rimraf": "^3.0.2",
|
||||||
"@types/ssh2": "^0.5.46",
|
"@types/ssh2": "^0.5.52",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.4",
|
||||||
"bcrypt": "^5.0.1",
|
"@types/ws": "^8.5.5",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
"busboy": "^0.3.1",
|
"busboy": "^0.3.1",
|
||||||
"cli-table": "^0.3.6",
|
"cli-table": "^0.3.11",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.6.0",
|
||||||
|
"ioredis": "^4.28.5",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"mkdirp": "^1.0.4",
|
"mkdirp": "^1.0.4",
|
||||||
"negotiator": "^0.6.2",
|
"negotiator": "^0.6.3",
|
||||||
"pg": "^8.6.0",
|
"node-fetch": "^3.3.1",
|
||||||
|
"pg": "^8.11.0",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ssh2": "^1.1.0",
|
"sqlite": "^4.2.1",
|
||||||
"ts-node": "^9.1.1",
|
"sqlite3": "^5.1.6",
|
||||||
"typedoc": "^0.20.36",
|
"ssh2": "^1.13.0",
|
||||||
"typedoc-plugin-pages-fork": "^0.0.1",
|
"ts-node": "^10.9.1",
|
||||||
"typedoc-plugin-sourcefile-url": "^1.0.6",
|
"typescript": "^4.9.5",
|
||||||
"typescript": "^4.2.3",
|
"uuid": "^8.3.2",
|
||||||
"uuid": "^8.3.2"
|
"ws": "^8.13.0",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
|
||||||
"prebuild": "pnpm run lint",
|
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||||
"build": "tsc",
|
|
||||||
"app": "tsc && node lib/index.js",
|
"app": "tsc && node lib/index.js",
|
||||||
"prepare": "pnpm run build",
|
"prepare": "pnpm run build",
|
||||||
"docs:build": "typedoc --options typedoc.json",
|
"docs:build": "typedoc --options typedoc.json",
|
||||||
|
"docs:build:docker": "pnpm run docs:build && docker image build docs -t ${DOCKER_REGISTRY}/extollo/docs:latest && docker push ${DOCKER_REGISTRY}/extollo/docs:latest",
|
||||||
"lint": "eslint . --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"lint:fix": "eslint --fix . --ext .ts"
|
"lint:fix": "eslint --fix . --ext .ts"
|
||||||
},
|
},
|
||||||
@ -62,8 +73,28 @@
|
|||||||
"author": "garrettmills <shout@garrettmills.dev>",
|
"author": "garrettmills <shout@garrettmills.dev>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
"@knodes/typedoc-plugin-pages": "^0.23.4",
|
||||||
"@typescript-eslint/parser": "^4.26.0",
|
"@types/chai": "^4.3.5",
|
||||||
"eslint": "^7.27.0"
|
"@types/mocha": "^9.1.1",
|
||||||
|
"@types/sinon": "^10.0.15",
|
||||||
|
"@types/wtfnode": "^0.7.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.11",
|
||||||
|
"@typescript-eslint/parser": "^5.59.11",
|
||||||
|
"chai": "^4.3.7",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"lunr": "^2.3.9",
|
||||||
|
"mocha": "^9.2.2",
|
||||||
|
"sinon": "^12.0.1",
|
||||||
|
"typedoc": "^0.23.28",
|
||||||
|
"wtfnode": "^0.9.1"
|
||||||
|
},
|
||||||
|
"extollo": {
|
||||||
|
"discover": true,
|
||||||
|
"units": {
|
||||||
|
"discover": false
|
||||||
|
},
|
||||||
|
"recursiveDependencies": {
|
||||||
|
"discover": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4830
pnpm-lock.yaml
4830
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import {ErrorWithContext} from '../util'
|
||||||
|
|
||||||
|
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||||
|
|
||||||
|
}
|
@ -1,16 +1,19 @@
|
|||||||
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {Injectable, Inject, StaticInstantiable} from '../di'
|
||||||
import {Logging} from '../service/Logging'
|
import {Logging} from '../service/Logging'
|
||||||
|
import {Middlewares} from '../service/Middlewares'
|
||||||
import {CanonicalResolver} from '../service/Canonical'
|
import {CanonicalResolver} from '../service/Canonical'
|
||||||
import {Middleware} from '../http/routing/Middleware'
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
|
||||||
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||||
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||||
import {Middlewares} from '../service/Middlewares'
|
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'
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit class that bootstraps the authentication framework.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class Authentication extends Unit {
|
export class Authentication extends Unit {
|
||||||
@Inject()
|
@Inject()
|
||||||
@ -19,21 +22,64 @@ export class Authentication extends Unit {
|
|||||||
@Inject()
|
@Inject()
|
||||||
protected readonly middleware!: Middlewares
|
protected readonly middleware!: Middlewares
|
||||||
|
|
||||||
async up(): Promise<void> {
|
@Inject()
|
||||||
this.container()
|
protected readonly config!: Config
|
||||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
|
||||||
|
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
|
||||||
|
|
||||||
|
getProvider(name: string): LoginProvider<LoginProviderConfig> {
|
||||||
|
const provider = this.providers[name]
|
||||||
|
if ( !provider ) {
|
||||||
|
throw new ErrorWithContext('Invalid auth provider name: ' + name, { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return provider
|
||||||
* Create the canonical namespace resolver for auth middleware.
|
}
|
||||||
* @protected
|
|
||||||
*/
|
async up(): Promise<void> {
|
||||||
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
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 (key: string) => {
|
||||||
return ({
|
return ({
|
||||||
web: SessionAuthMiddleware,
|
|
||||||
required: AuthRequiredMiddleware,
|
required: AuthRequiredMiddleware,
|
||||||
guest: GuestRequiredMiddleware,
|
guest: GuestRequiredMiddleware,
|
||||||
|
web: SessionAuthMiddleware,
|
||||||
})[key]
|
})[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,143 +0,0 @@
|
|||||||
import {Inject, Injectable} from '../di'
|
|
||||||
import {EventBus} from '../event/EventBus'
|
|
||||||
import {Awaitable, Maybe} from '../util'
|
|
||||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
|
||||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
|
||||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
|
||||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base-class for a context that authenticates users and manages security.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export abstract class SecurityContext {
|
|
||||||
@Inject()
|
|
||||||
protected readonly bus!: EventBus
|
|
||||||
|
|
||||||
/** The currently authenticated user, if one exists. */
|
|
||||||
private authenticatedUser?: Authenticatable
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
/** The repository from which to draw users. */
|
|
||||||
public readonly repository: AuthenticatableRepository,
|
|
||||||
|
|
||||||
/** The name of this context. */
|
|
||||||
public readonly name: string,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the context is created. Can be used by child-classes to do setup work.
|
|
||||||
*/
|
|
||||||
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate the given user, without persisting the authentication.
|
|
||||||
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
|
|
||||||
* @param user
|
|
||||||
*/
|
|
||||||
async authenticateOnce(user: Authenticatable): Promise<void> {
|
|
||||||
this.authenticatedUser = user
|
|
||||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate the given user and persist the authentication.
|
|
||||||
* @param user
|
|
||||||
*/
|
|
||||||
async authenticate(user: Authenticatable): Promise<void> {
|
|
||||||
this.authenticatedUser = user
|
|
||||||
await this.persist()
|
|
||||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to authenticate a user based on their credentials.
|
|
||||||
* If the credentials are valid, the user will be authenticated, but the authentication
|
|
||||||
* will not be persisted. That is, when the lifecycle ends, the user will be
|
|
||||||
* unauthenticated implicitly.
|
|
||||||
* @param credentials
|
|
||||||
*/
|
|
||||||
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
|
||||||
const user = await this.repository.getByCredentials(credentials)
|
|
||||||
if ( user ) {
|
|
||||||
await this.authenticateOnce(user)
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to authenticate a user based on their credentials.
|
|
||||||
* If the credentials are valid, the user will be authenticated and the
|
|
||||||
* authentication will be persisted.
|
|
||||||
* @param credentials
|
|
||||||
*/
|
|
||||||
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
|
||||||
const user = await this.repository.getByCredentials(credentials)
|
|
||||||
if ( user ) {
|
|
||||||
await this.authenticate(user)
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthenticate the current user, if one exists, but do not persist the change.
|
|
||||||
*/
|
|
||||||
async flushOnce(): Promise<void> {
|
|
||||||
const user = this.authenticatedUser
|
|
||||||
if ( user ) {
|
|
||||||
this.authenticatedUser = undefined
|
|
||||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unauthenticate the current user, if one exists, and persist the change.
|
|
||||||
*/
|
|
||||||
async flush(): Promise<void> {
|
|
||||||
const user = this.authenticatedUser
|
|
||||||
if ( user ) {
|
|
||||||
this.authenticatedUser = undefined
|
|
||||||
await this.persist()
|
|
||||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assuming a user is still authenticated in the context,
|
|
||||||
* try to look up and fill in the user.
|
|
||||||
*/
|
|
||||||
async resume(): Promise<void> {
|
|
||||||
const credentials = await this.getCredentials()
|
|
||||||
const user = await this.repository.getByCredentials(credentials)
|
|
||||||
if ( user ) {
|
|
||||||
this.authenticatedUser = user
|
|
||||||
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the current state of the security context to whatever storage
|
|
||||||
* medium the context's host provides.
|
|
||||||
*/
|
|
||||||
abstract persist(): Awaitable<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the credentials for the current user from whatever storage medium
|
|
||||||
* the context's host provides.
|
|
||||||
*/
|
|
||||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the currently authenticated user, if one exists.
|
|
||||||
*/
|
|
||||||
getUser(): Maybe<Authenticatable> {
|
|
||||||
return this.authenticatedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if there is a currently authenticated user.
|
|
||||||
*/
|
|
||||||
hasUser(): boolean {
|
|
||||||
return Boolean(this.authenticatedUser)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,25 +1,51 @@
|
|||||||
import {Instantiable} from '../di'
|
import {Instantiable, isInstantiable} from '../di'
|
||||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
import {AuthenticatableRepository} from './types'
|
||||||
|
import {hasOwnProperty} from '../util'
|
||||||
|
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
|
||||||
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
|
|
||||||
/**
|
export interface AuthenticationConfig {
|
||||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
storage: Instantiable<AuthenticatableRepository>,
|
||||||
*/
|
middleware?: Instantiable<Middleware>,
|
||||||
export interface AuthenticatableRepositoryMapping {
|
providers?: {
|
||||||
orm: Instantiable<ORMUserRepository>,
|
[key: string]: {
|
||||||
|
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
|
||||||
|
config: LoginProviderConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig {
|
||||||
* String mapping of AuthenticatableRepository implementations.
|
if ( typeof what !== 'object' || !what ) {
|
||||||
*/
|
return false
|
||||||
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
|
||||||
orm: ORMUserRepository,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
|
||||||
* Interface for making the auth config type-safe.
|
return false
|
||||||
*/
|
}
|
||||||
export interface AuthConfig {
|
|
||||||
repositories: {
|
if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) {
|
||||||
session: keyof AuthenticatableRepositoryMapping,
|
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
|
||||||
|
}
|
||||||
|
122
src/auth/context/SecurityContext.ts
Normal file
122
src/auth/context/SecurityContext.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* If there is NO USER to be resumed, then the method should flush
|
||||||
|
* the user from this context.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
42
src/auth/context/SessionSecurityContext.ts
Normal file
42
src/auth/context/SessionSecurityContext.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authenticatedUser = undefined
|
||||||
|
}
|
||||||
|
}
|
44
src/auth/context/TokenSecurityContext.ts
Normal file
44
src/auth/context/TokenSecurityContext.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {SecurityContext} from './SecurityContext'
|
||||||
|
import {AuthenticatableRepository} from '../types'
|
||||||
|
import {Awaitable} from '../../util'
|
||||||
|
import {Inject} from '../../di'
|
||||||
|
import {Request} from '../../http/lifecycle/Request'
|
||||||
|
import {OAuth2Token, TokenRepository} from '../server/types'
|
||||||
|
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
||||||
|
|
||||||
|
export class TokenSecurityContext extends SecurityContext {
|
||||||
|
@Inject()
|
||||||
|
protected readonly request!: Request
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly tokens!: TokenRepository
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly repository: AuthenticatableRepository,
|
||||||
|
) {
|
||||||
|
super(repository, 'token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
persist(): Awaitable<void> {}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if ( !this.request.hasInstance(OAuth2Token) ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
||||||
|
if ( !token.userId ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.repository.getByIdentifier(token.userId)
|
||||||
|
if ( user ) {
|
||||||
|
this.authenticatedUser = user
|
||||||
|
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authenticatedUser = undefined
|
||||||
|
}
|
||||||
|
}
|
@ -1,31 +0,0 @@
|
|||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Inject, Injectable} from '../../di'
|
|
||||||
import {Session} from '../../http/session/Session'
|
|
||||||
import {Awaitable} from '../../util'
|
|
||||||
import {AuthenticatableRepository} from '../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security context implementation that uses the session as storage.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SessionSecurityContext extends SecurityContext {
|
|
||||||
@Inject()
|
|
||||||
protected readonly session!: Session
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
/** The repository from which to draw users. */
|
|
||||||
public readonly repository: AuthenticatableRepository,
|
|
||||||
) {
|
|
||||||
super(repository, 'session')
|
|
||||||
}
|
|
||||||
|
|
||||||
getCredentials(): Awaitable<Record<string, string>> {
|
|
||||||
return {
|
|
||||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
persist(): Awaitable<void> {
|
|
||||||
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
|
|
||||||
}
|
|
||||||
}
|
|
27
src/auth/event/AuthCheckFailed.ts
Normal file
27
src/auth/event/AuthCheckFailed.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import {BaseEvent, BaseSerializer, ObjectSerializer} from '../../support/bus'
|
||||||
|
import {Awaitable} from '../../util'
|
||||||
|
|
||||||
|
/** An event raised when a required auth check has failed. */
|
||||||
|
export class AuthCheckFailed extends BaseEvent {
|
||||||
|
eventName = '@extollo/lib:AuthCheckFailed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes AuthCheckFailed events. */
|
||||||
|
@ObjectSerializer()
|
||||||
|
export class AuthCheckFailedSerializer extends BaseSerializer<AuthCheckFailed, { authCheckFailed: true }> {
|
||||||
|
protected decodeSerial(): Awaitable<AuthCheckFailed> {
|
||||||
|
return new AuthCheckFailed()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected encodeActual(): Awaitable<{ authCheckFailed: true }> {
|
||||||
|
return { authCheckFailed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib:AuthCheckFailedSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: AuthCheckFailed): boolean {
|
||||||
|
return some instanceof AuthCheckFailed
|
||||||
|
}
|
||||||
|
}
|
12
src/auth/event/AuthenticationEvent.ts
Normal file
12
src/auth/event/AuthenticationEvent.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +1,8 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a user is authenticated.
|
* Event fired when a user is authenticated.
|
||||||
*/
|
*/
|
||||||
export class UserAuthenticatedEvent extends Event {
|
export class UserAuthenticatedEvent extends AuthenticationEvent {
|
||||||
constructor(
|
public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
|
||||||
public readonly user: Authenticatable,
|
|
||||||
public readonly context: SecurityContext,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
|
||||||
return {
|
|
||||||
user: await this.user.dehydrate(),
|
|
||||||
contextName: this.context.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
||||||
// TODO fill this in
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,8 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a security context for a given user is resumed.
|
* Event raised when a user is re-authenticated to a security context
|
||||||
*/
|
*/
|
||||||
export class UserAuthenticationResumedEvent extends Event {
|
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
|
||||||
constructor(
|
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
|
||||||
public readonly user: Authenticatable,
|
|
||||||
public readonly context: SecurityContext,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
|
||||||
return {
|
|
||||||
user: await this.user.dehydrate(),
|
|
||||||
contextName: this.context.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
||||||
// TODO fill this in
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,8 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a user is unauthenticated.
|
* Event fired when a user is unauthenticated.
|
||||||
*/
|
*/
|
||||||
export class UserFlushedEvent extends Event {
|
export class UserFlushedEvent extends AuthenticationEvent {
|
||||||
constructor(
|
public readonly eventName = '@extollo/lib:UserFlushedEvent'
|
||||||
public readonly user: Authenticatable,
|
|
||||||
public readonly context: SecurityContext,
|
|
||||||
) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
|
||||||
return {
|
|
||||||
user: await this.user.dehydrate(),
|
|
||||||
contextName: this.context.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
||||||
// TODO fill this in
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,49 @@
|
|||||||
export * from './types'
|
export * from './types'
|
||||||
|
export * from './AuthenticatableAlreadyExistsError'
|
||||||
export * from './NotAuthorizedError'
|
export * from './NotAuthorizedError'
|
||||||
|
export * from './Authentication'
|
||||||
|
export * from './repository/AuthenticatableRepositoryFactory'
|
||||||
|
|
||||||
export * from './SecurityContext'
|
export * from './context/SecurityContext'
|
||||||
|
export * from './context/SessionSecurityContext'
|
||||||
|
export * from './context/TokenSecurityContext'
|
||||||
|
|
||||||
|
export * from './event/AuthenticationEvent'
|
||||||
export * from './event/UserAuthenticatedEvent'
|
export * from './event/UserAuthenticatedEvent'
|
||||||
export * from './event/UserFlushedEvent'
|
|
||||||
export * from './event/UserAuthenticationResumedEvent'
|
export * from './event/UserAuthenticationResumedEvent'
|
||||||
|
export * from './event/UserFlushedEvent'
|
||||||
export * from './contexts/SessionSecurityContext'
|
export * from './event/AuthCheckFailed'
|
||||||
|
|
||||||
export * from './orm/ORMUser'
|
|
||||||
export * from './orm/ORMUserRepository'
|
|
||||||
|
|
||||||
export * from './middleware/AuthRequiredMiddleware'
|
export * from './middleware/AuthRequiredMiddleware'
|
||||||
export * from './middleware/GuestRequiredMiddleware'
|
export * from './middleware/GuestRequiredMiddleware'
|
||||||
export * from './middleware/SessionAuthMiddleware'
|
export * from './middleware/SessionAuthMiddleware'
|
||||||
|
export * from './middleware/TokenAuthMiddleware'
|
||||||
|
export * from './middleware/ScopeRequiredMiddleware'
|
||||||
|
|
||||||
export * from './Authentication'
|
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 './config'
|
||||||
|
|
||||||
|
export * from './webSocketAuthCheck'
|
||||||
|
|
||||||
|
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'
|
||||||
|
@ -1,19 +1,36 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
import {error} from '../../http/response/ErrorResponseFactory'
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
import {HTTPStatus} from '../../util'
|
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()
|
@Injectable()
|
||||||
export class AuthRequiredMiddleware extends Middleware {
|
export class AuthRequiredMiddleware extends Middleware {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly security!: SecurityContext
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly session!: Session
|
||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
async apply(): Promise<ResponseObject> {
|
||||||
if ( !this.security.hasUser() ) {
|
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)
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -1,19 +1,30 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
import {error} from '../../http/response/ErrorResponseFactory'
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
import {HTTPStatus} from '../../util'
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
|
||||||
|
// TODO handle JSON and non-web
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GuestRequiredMiddleware extends Middleware {
|
export class GuestRequiredMiddleware extends Middleware {
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly security!: SecurityContext
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
async apply(): Promise<ResponseObject> {
|
||||||
if ( this.security.hasUser() ) {
|
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)
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
33
src/auth/middleware/ScopeRequiredMiddleware.ts
Normal file
33
src/auth/middleware/ScopeRequiredMiddleware.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {OAuth2Token} from '../server/types'
|
||||||
|
import {HTTPError} from '../../http/HTTPError'
|
||||||
|
import {HTTPStatus, Pipeline} from '../../util'
|
||||||
|
import {Request} from '../../http/lifecycle/Request'
|
||||||
|
import {Constructable, Container} from '../../di'
|
||||||
|
|
||||||
|
export class ScopeRequiredMiddleware extends Middleware {
|
||||||
|
constructor(
|
||||||
|
protected readonly request: Request,
|
||||||
|
protected readonly scope: string,
|
||||||
|
) {
|
||||||
|
super(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(): ResponseObject {
|
||||||
|
if ( !this.request.hasInstance(OAuth2Token) ) {
|
||||||
|
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Must specify an OAuth2 token.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
|
||||||
|
if ( typeof token.scope !== 'undefined' && token.scope !== this.scope ) {
|
||||||
|
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Insufficient token permissions (requires: ' + this.scope + ')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scope = (name: string): Constructable<ScopeRequiredMiddleware> => {
|
||||||
|
return new Pipeline<Container, ScopeRequiredMiddleware>(
|
||||||
|
container => container.make(ScopeRequiredMiddleware, container, name),
|
||||||
|
)
|
||||||
|
}
|
@ -1,12 +1,11 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
|
||||||
import {Config} from '../../service/Config'
|
import {Config} from '../../service/Config'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
import {AuthenticatableRepository} from '../types'
|
import {AuthenticatableRepository} from '../types'
|
||||||
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {SessionSecurityContext} from '../context/SessionSecurityContext'
|
||||||
import {ORMUserRepository} from '../orm/ORMUserRepository'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {AuthConfig, AuthenticatableRepositories} from '../config'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injects a SessionSecurityContext into the request and attempts to
|
* Injects a SessionSecurityContext into the request and attempts to
|
||||||
@ -17,19 +16,14 @@ export class SessionAuthMiddleware extends Middleware {
|
|||||||
@Inject()
|
@Inject()
|
||||||
protected readonly config!: Config
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
async apply(): Promise<ResponseObject> {
|
||||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
this.logging.debug('Applying session auth middleware.')
|
||||||
|
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
||||||
|
const context = <SessionSecurityContext> this.make(SessionSecurityContext, repo)
|
||||||
this.request.registerSingletonInstance(SecurityContext, context)
|
this.request.registerSingletonInstance(SecurityContext, context)
|
||||||
await context.resume()
|
await context.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the correct AuthenticatableRepository based on the auth config.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected getRepository(): AuthenticatableRepository {
|
|
||||||
const config: AuthConfig | undefined = this.config.get('auth')
|
|
||||||
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
|
|
||||||
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
45
src/auth/middleware/TokenAuthMiddleware.ts
Normal file
45
src/auth/middleware/TokenAuthMiddleware.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {AuthenticatableRepository} from '../types'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
import {TokenSecurityContext} from '../context/TokenSecurityContext'
|
||||||
|
import {OAuth2Token, oauth2TokenString, TokenRepository} from '../server/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a TokenSecurityContext into the request and attempts to
|
||||||
|
* resume the user's authentication.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TokenAuthMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly tokens!: TokenRepository
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
this.logging.debug('Applying token auth middleware.')
|
||||||
|
let tokenString = this.request.getHeader('Authorization')
|
||||||
|
if ( Array.isArray(tokenString) ) {
|
||||||
|
tokenString = tokenString[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( tokenString ) {
|
||||||
|
const token = await this.tokens.decode(oauth2TokenString(tokenString))
|
||||||
|
if ( token ) {
|
||||||
|
this.request.registerSingletonInstance(OAuth2Token, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
||||||
|
const context = <TokenSecurityContext> this.make(TokenSecurityContext, repo)
|
||||||
|
this.request.registerSingletonInstance(SecurityContext, context)
|
||||||
|
await context.resume()
|
||||||
|
}
|
||||||
|
}
|
@ -1,41 +0,0 @@
|
|||||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
|
||||||
import {Awaitable, Maybe} from '../../util'
|
|
||||||
import {ORMUser} from './ORMUser'
|
|
||||||
import {Injectable} from '../../di'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A user repository implementation that looks up users stored in the database.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ORMUserRepository extends AuthenticatableRepository {
|
|
||||||
/** Look up the user by their username. */
|
|
||||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
|
||||||
return ORMUser.query<ORMUser>()
|
|
||||||
.where('username', '=', id)
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to look up a user by the credentials provided.
|
|
||||||
* If a securityIdentifier is specified, look up the user by username.
|
|
||||||
* If username/password are specified, look up the user and verify the password.
|
|
||||||
* @param credentials
|
|
||||||
*/
|
|
||||||
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
|
||||||
if ( credentials.securityIdentifier ) {
|
|
||||||
return ORMUser.query<ORMUser>()
|
|
||||||
.where('username', '=', credentials.securityIdentifier)
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( credentials.username && credentials.password ) {
|
|
||||||
const user = await ORMUser.query<ORMUser>()
|
|
||||||
.where('username', '=', credentials.username)
|
|
||||||
.first()
|
|
||||||
|
|
||||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
74
src/auth/provider/LoginProvider.ts
Normal file
74
src/auth/provider/LoginProvider.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
8
src/auth/provider/basic/BasicLoginAttempt.ts
Normal file
8
src/auth/provider/basic/BasicLoginAttempt.ts
Normal 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(),
|
||||||
|
})
|
75
src/auth/provider/basic/BasicLoginProvider.ts
Normal file
75
src/auth/provider/basic/BasicLoginProvider.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
19
src/auth/provider/basic/BasicRegistrationAttempt.ts
Normal file
19
src/auth/provider/basic/BasicRegistrationAttempt.ts
Normal 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),
|
||||||
|
})
|
99
src/auth/provider/oauth/CoreIDLoginProvider.ts
Normal file
99
src/auth/provider/oauth/CoreIDLoginProvider.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/* 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 = request.safe('code').string()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if ( typeof user.save === 'function' ) {
|
||||||
|
user.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
98
src/auth/provider/oauth/OAuth2LoginProvider.ts
Normal file
98
src/auth/provider/oauth/OAuth2LoginProvider.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/repository/AuthenticatableRepositoryFactory.ts
Normal file
23
src/auth/repository/AuthenticatableRepositoryFactory.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {Instantiable, FactoryProducer} from '../../di'
|
||||||
|
import {AuthenticatableRepository} from '../types'
|
||||||
|
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||||
|
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dependency injection factory that matches the abstract ClientRepository class
|
||||||
|
* and produces an instance of the configured repository driver implementation.
|
||||||
|
*/
|
||||||
|
@FactoryProducer()
|
||||||
|
export class AuthenticatableRepositoryFactory extends ConfiguredSingletonFactory<AuthenticatableRepository> {
|
||||||
|
protected getConfigKey(): string {
|
||||||
|
return 'auth.storage'
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDefaultImplementation(): Instantiable<AuthenticatableRepository> {
|
||||||
|
return ORMUserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAbstractImplementation(): any {
|
||||||
|
return AuthenticatableRepository
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import {Field, FieldType, Model} from '../../orm'
|
|
||||||
import {Authenticatable, AuthenticatableIdentifier} from '../types'
|
|
||||||
import {Injectable} from '../../di'
|
|
||||||
import * as bcrypt from 'bcrypt'
|
import * as bcrypt from 'bcrypt'
|
||||||
import {Awaitable, JSONState} from '../../util'
|
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.
|
* A basic ORM-driven user class.
|
||||||
@ -24,21 +24,30 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
|||||||
|
|
||||||
/** The user's first name. */
|
/** The user's first name. */
|
||||||
@Field(FieldType.varchar, 'first_name')
|
@Field(FieldType.varchar, 'first_name')
|
||||||
public firstName!: string
|
public firstName?: string
|
||||||
|
|
||||||
/** The user's last name. */
|
/** The user's last name. */
|
||||||
@Field(FieldType.varchar, 'last_name')
|
@Field(FieldType.varchar, 'last_name')
|
||||||
public lastName!: string
|
public lastName?: string
|
||||||
|
|
||||||
/** The hashed and salted password of the user. */
|
/** The hashed and salted password of the user. */
|
||||||
@Field(FieldType.varchar, 'password_hash')
|
@Field(FieldType.varchar, 'password_hash')
|
||||||
public passwordHash!: string
|
public passwordHash!: string
|
||||||
|
|
||||||
/** Human-readable display name of the user. */
|
/** Human-readable display name of the user. */
|
||||||
getDisplayIdentifier(): string {
|
getDisplay(): string {
|
||||||
|
if ( this.firstName || this.lastName ) {
|
||||||
return `${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. */
|
/** Unique identifier of the user. */
|
||||||
getIdentifier(): AuthenticatableIdentifier {
|
getIdentifier(): AuthenticatableIdentifier {
|
||||||
return this.username
|
return this.username
|
||||||
@ -54,6 +63,10 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
|||||||
this.passwordHash = await bcrypt.hash(password, 10)
|
this.passwordHash = await bcrypt.hash(password, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateCredential(credential: string): Awaitable<boolean> {
|
||||||
|
return this.verifyPassword(credential)
|
||||||
|
}
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
async dehydrate(): Promise<JSONState> {
|
||||||
return this.toQueryRow()
|
return this.toQueryRow()
|
||||||
}
|
}
|
51
src/auth/repository/orm/ORMUserRepository.ts
Normal file
51
src/auth/repository/orm/ORMUserRepository.ts
Normal 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<ORMUser>()
|
||||||
|
.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())
|
||||||
|
}
|
||||||
|
}
|
54
src/auth/serial/AuthenticationEventSerializer.ts
Normal file
54
src/auth/serial/AuthenticationEventSerializer.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
241
src/auth/server/OAuth2Server.ts
Normal file
241
src/auth/server/OAuth2Server.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import {Controller} from '../../http/Controller'
|
||||||
|
import {Inject, 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,
|
||||||
|
TokenRepository,
|
||||||
|
} 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'
|
||||||
|
import {one} from '../../http/response/api'
|
||||||
|
import {AuthenticatableRepository} from '../types'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
|
||||||
|
export enum GrantType {
|
||||||
|
Client = 'client_credentials',
|
||||||
|
Password = 'password',
|
||||||
|
Code = 'authorization_code',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const grantTypes: GrantType[] = [GrantType.Client, GrantType.Code, GrantType.Password]
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OAuth2Server extends Controller {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
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/token')
|
||||||
|
.alias('@oauth2:token')
|
||||||
|
.passingRequest()
|
||||||
|
.calls<OAuth2Server>(OAuth2Server, x => x.issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue(request: Request): Promise<ResponseObject> {
|
||||||
|
const grant = request.safe('grant_type').in(grantTypes)
|
||||||
|
const client = await this.getClientFromRequest(request)
|
||||||
|
|
||||||
|
if ( grant === GrantType.Client ) {
|
||||||
|
return this.issueFromClient(request, client)
|
||||||
|
} else if ( grant === GrantType.Code ) {
|
||||||
|
return this.issueFromCode(request, client)
|
||||||
|
} else if ( grant === GrantType.Password ) {
|
||||||
|
return this.issueFromCredential(request, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async issueFromCredential(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||||
|
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||||
|
const username = this.request.safe('username').string()
|
||||||
|
const password = this.request.safe('password').string()
|
||||||
|
|
||||||
|
this.logging.verbose('Attempting password grant token issue...')
|
||||||
|
this.logging.verbose({
|
||||||
|
scope,
|
||||||
|
username,
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
|
||||||
|
const user = await userRepo.getByIdentifier(username)
|
||||||
|
if ( !user || !(await user.validateCredential(password)) ) {
|
||||||
|
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||||
|
const token = await tokenRepo.issue(user, client, scope)
|
||||||
|
return one({
|
||||||
|
token: await tokenRepo.encode(token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async issueFromCode(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||||
|
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
|
||||||
|
const user = await userRepo.getByIdentifier(code.userId)
|
||||||
|
if ( !user ) {
|
||||||
|
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||||
|
const token = await tokenRepo.issue(user, client, scope)
|
||||||
|
return one({
|
||||||
|
token: await tokenRepo.encode(token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async issueFromClient(request: Request, client: OAuth2Client): Promise<ResponseObject> {
|
||||||
|
const scope = String(this.request.input('scope') ?? '') || undefined
|
||||||
|
|
||||||
|
const tokenRepo = <TokenRepository> request.make(TokenRepository)
|
||||||
|
const token = await tokenRepo.issue(undefined, client, scope)
|
||||||
|
return one({
|
||||||
|
token: await tokenRepo.encode(token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getClientFromRequest(request: Request): Promise<OAuth2Client> {
|
||||||
|
const authParts = String(request.getHeader('Authorization')).split(':')
|
||||||
|
if ( authParts.length !== 2 ) {
|
||||||
|
throw new HTTPError(HTTPStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logging.debug('Client auth parts:')
|
||||||
|
this.logging.debug(authParts)
|
||||||
|
|
||||||
|
const clientRepo = <ClientRepository> request.make(ClientRepository)
|
||||||
|
const [clientId, clientSecret] = authParts
|
||||||
|
const client = await clientRepo.find(clientId)
|
||||||
|
|
||||||
|
this.logging.verbose('Client:')
|
||||||
|
this.logging.verbose(client)
|
||||||
|
|
||||||
|
if ( !client || client.secret !== clientSecret ) {
|
||||||
|
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorizeAndRedirect(request: Request): Promise<ResponseObject> {
|
||||||
|
// Look up the client in the client repo
|
||||||
|
const session = <Session> request.make(Session)
|
||||||
|
const client = await this.getClientFromRequest(request)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
30
src/auth/server/models/OAuth2TokenModel.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {Field, FieldType, Model} from '../../../orm'
|
||||||
|
import {OAuth2Token} from '../types'
|
||||||
|
|
||||||
|
export class OAuth2TokenModel extends Model<OAuth2TokenModel> 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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal file
23
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {Instantiable, FactoryProducer} from '../../../di'
|
||||||
|
import {ClientRepository} from '../types'
|
||||||
|
import {ConfigClientRepository} from './ConfigClientRepository'
|
||||||
|
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dependency injection factory that matches the abstract ClientRepository class
|
||||||
|
* and produces an instance of the configured repository driver implementation.
|
||||||
|
*/
|
||||||
|
@FactoryProducer()
|
||||||
|
export class ClientRepositoryFactory extends ConfiguredSingletonFactory<ClientRepository> {
|
||||||
|
protected getConfigKey(): string {
|
||||||
|
return 'oauth2.repository.client'
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDefaultImplementation(): Instantiable<ClientRepository> {
|
||||||
|
return ConfigClientRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAbstractImplementation(): any {
|
||||||
|
return ClientRepository
|
||||||
|
}
|
||||||
|
}
|
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal file
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal file
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
94
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
94
src/auth/server/repositories/ORMTokenRepository.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {ErrorWithContext, Maybe} from '../../../util'
|
||||||
|
import {OAuth2TokenModel} from '../models/OAuth2TokenModel'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
|
import * as jwt from 'jsonwebtoken'
|
||||||
|
import {Authenticatable} from '../../types'
|
||||||
|
import {make} from '../../../make'
|
||||||
|
|
||||||
|
@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<OAuth2TokenModel>()
|
||||||
|
.whereKey(idNum)
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
|
||||||
|
const expiration = this.config.safe('outh2.token.lifetimeSeconds')
|
||||||
|
.or(60 * 60 * 6)
|
||||||
|
.integer() * 1000
|
||||||
|
|
||||||
|
const token = make<OAuth2TokenModel>(OAuth2TokenModel)
|
||||||
|
token.scope = scope
|
||||||
|
token.clientId = client.id
|
||||||
|
token.issued = new Date()
|
||||||
|
token.expires = new Date(Math.floor(Date.now() + expiration))
|
||||||
|
if ( user ) {
|
||||||
|
token.userId = String(user.getUniqueIdentifier())
|
||||||
|
}
|
||||||
|
await token.save()
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(token: OAuth2Token): Promise<OAuth2TokenString> {
|
||||||
|
const secret = this.config.safe('oauth2.secret').string()
|
||||||
|
const payload = {
|
||||||
|
id: token.id,
|
||||||
|
clientId: token.clientId,
|
||||||
|
iat: Math.floor(token.issued.valueOf() / 1000),
|
||||||
|
exp: Math.floor(token.expires.valueOf() / 1000),
|
||||||
|
...(token.userId ? { userId: token.userId } : {}),
|
||||||
|
...(token.scope ? { scope: token.scope } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = await new Promise<string>((res, rej) => {
|
||||||
|
jwt.sign(payload, secret, {}, (err, gen) => {
|
||||||
|
if (err || !gen) {
|
||||||
|
rej(err || new ErrorWithContext('Unable to encode JWT.', {
|
||||||
|
payload,
|
||||||
|
gen,
|
||||||
|
}))
|
||||||
|
} 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,
|
||||||
|
clientId: decoded.clientId,
|
||||||
|
issued: new Date(decoded.iat * 1000),
|
||||||
|
expires: new Date(decoded.exp * 1000),
|
||||||
|
...(decoded.userId ? { userId: decoded.userId } : {}),
|
||||||
|
...(decoded.scope ? { scope: decoded.scope } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isOAuth2Token(value) ) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import {Instantiable, FactoryProducer} from '../../../di'
|
||||||
|
import {RedemptionCodeRepository} from '../types'
|
||||||
|
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
|
||||||
|
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ConfiguredSingletonFactory<RedemptionCodeRepository> {
|
||||||
|
protected getConfigKey(): string {
|
||||||
|
return 'oauth2.repository.client'
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDefaultImplementation(): Instantiable<RedemptionCodeRepository> {
|
||||||
|
return CacheRedemptionCodeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAbstractImplementation(): any {
|
||||||
|
return RedemptionCodeRepository
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal file
23
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {Instantiable, FactoryProducer} from '../../../di'
|
||||||
|
import {ScopeRepository} from '../types'
|
||||||
|
import {ConfigScopeRepository} from './ConfigScopeRepository'
|
||||||
|
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ConfiguredSingletonFactory<ScopeRepository> {
|
||||||
|
protected getConfigKey(): string {
|
||||||
|
return 'oauth2.repository.scope'
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDefaultImplementation(): Instantiable<ScopeRepository> {
|
||||||
|
return ConfigScopeRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAbstractImplementation(): any {
|
||||||
|
return ScopeRepository
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
23
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {Instantiable, FactoryProducer} from '../../../di'
|
||||||
|
import {TokenRepository} from '../types'
|
||||||
|
import {ORMTokenRepository} from './ORMTokenRepository'
|
||||||
|
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ConfiguredSingletonFactory<TokenRepository> {
|
||||||
|
protected getConfigKey(): string {
|
||||||
|
return 'oauth2.repository.token'
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDefaultImplementation(): Instantiable<TokenRepository> {
|
||||||
|
return ORMTokenRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAbstractImplementation(): any {
|
||||||
|
return TokenRepository
|
||||||
|
}
|
||||||
|
}
|
179
src/auth/server/types.ts
Normal file
179
src/auth/server/types.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
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 abstract class OAuth2Token {
|
||||||
|
abstract id: string
|
||||||
|
|
||||||
|
/** When undefined, these are client credentials. */
|
||||||
|
abstract userId?: AuthenticatableIdentifier
|
||||||
|
|
||||||
|
abstract clientId: string
|
||||||
|
|
||||||
|
abstract issued: Date
|
||||||
|
|
||||||
|
abstract expires: Date
|
||||||
|
|
||||||
|
abstract 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, 'clientId')
|
||||||
|
|| !hasOwnProperty(what, 'issued')
|
||||||
|
|| !hasOwnProperty(what, 'expires')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof what.id !== 'string'
|
||||||
|
|| (hasOwnProperty(what, 'userId') && !(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|undefined, 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>
|
||||||
|
}
|
@ -8,11 +8,17 @@ export type AuthenticatableIdentifier = string | number
|
|||||||
*/
|
*/
|
||||||
export abstract class Authenticatable implements Rehydratable {
|
export abstract class Authenticatable implements Rehydratable {
|
||||||
|
|
||||||
/** Get the unique identifier of the user. */
|
/** Get the globally-unique identifier of the user. */
|
||||||
|
abstract getUniqueIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
|
/** Get the repository-unique identifier of the user. */
|
||||||
abstract getIdentifier(): AuthenticatableIdentifier
|
abstract getIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
/** Get the human-readable identifier of the user. */
|
/** Get the human-readable identifier of the user. */
|
||||||
abstract getDisplayIdentifier(): string
|
abstract getDisplay(): string
|
||||||
|
|
||||||
|
/** Attempt to validate a credential of the user. */
|
||||||
|
abstract validateCredential(credential: string): Awaitable<boolean>
|
||||||
|
|
||||||
abstract dehydrate(): Promise<JSONState>
|
abstract dehydrate(): Promise<JSONState>
|
||||||
|
|
||||||
@ -23,14 +29,15 @@ export abstract class Authenticatable implements Rehydratable {
|
|||||||
* Base class for a repository that stores and recalls users.
|
* Base class for a repository that stores and recalls users.
|
||||||
*/
|
*/
|
||||||
export abstract class AuthenticatableRepository {
|
export abstract class AuthenticatableRepository {
|
||||||
|
|
||||||
/** Look up the user by their unique identifier. */
|
/** Look up the user by their unique identifier. */
|
||||||
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
/**
|
/** Returns true if this repository supports registering users. */
|
||||||
* Attempt to look up and verify a user by their credentials.
|
abstract supportsRegistration(): boolean
|
||||||
* Returns the user if the credentials are valid.
|
|
||||||
* @param credentials
|
/** Create a user in this repository from an external Authenticatable instance. */
|
||||||
*/
|
abstract createFromExternal(user: Authenticatable): Awaitable<Authenticatable>
|
||||||
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
|
||||||
|
/** Create a user in this repository from basic credentials. */
|
||||||
|
abstract createFromCredentials(username: string, password: string): Awaitable<Authenticatable>
|
||||||
}
|
}
|
||||||
|
36
src/auth/webSocketAuthCheck.ts
Normal file
36
src/auth/webSocketAuthCheck.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {Container} from '../di'
|
||||||
|
import {RequestLocalStorage} from '../http/RequestLocalStorage'
|
||||||
|
import {Session} from '../http/session/Session'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
import {SecurityContext} from './context/SecurityContext'
|
||||||
|
import {Bus} from '../support/bus'
|
||||||
|
import {AuthCheckFailed} from './event/AuthCheckFailed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the security context for the current request's web socket is still valid.
|
||||||
|
* If not, raise an `AuthCheckFailed` event. This is meant to be used as a subscriber
|
||||||
|
* to `WebSocketHealthCheckEvent` on the request.
|
||||||
|
*
|
||||||
|
* @see AuthCheckFailed
|
||||||
|
*/
|
||||||
|
export async function webSocketAuthCheck(): Promise<void> {
|
||||||
|
const request = Container.getContainer()
|
||||||
|
.make<RequestLocalStorage>(RequestLocalStorage)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
const logging = request.make<Logging>(Logging)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to re-load the session in case we're using the SessionSecurityContext
|
||||||
|
await request.make<Session>(Session).load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
logging.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const security = request.make<SecurityContext>(SecurityContext)
|
||||||
|
await security.resume()
|
||||||
|
|
||||||
|
if ( !security.hasUser() ) {
|
||||||
|
await request.make<Bus>(Bus).push(new AuthCheckFailed())
|
||||||
|
}
|
||||||
|
}
|
@ -169,8 +169,12 @@ export abstract class Directive extends AppClass {
|
|||||||
const optionValues = this.parseOptions(options, argv)
|
const optionValues = this.parseOptions(options, argv)
|
||||||
this.setOptionValues(optionValues)
|
this.setOptionValues(optionValues)
|
||||||
await this.handle(argv)
|
await this.handle(argv)
|
||||||
} catch (e) {
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
this.nativeOutput(e.message)
|
this.nativeOutput(e.message)
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
if ( e instanceof OptionValidationError ) {
|
if ( e instanceof OptionValidationError ) {
|
||||||
// expecting, value, requirements
|
// expecting, value, requirements
|
||||||
if ( e.context.expecting ) {
|
if ( e.context.expecting ) {
|
||||||
@ -187,6 +191,7 @@ export abstract class Directive extends AppClass {
|
|||||||
this.nativeOutput(` - ${e.context.value}`)
|
this.nativeOutput(` - ${e.context.value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nativeOutput('\nUse --help for more info.')
|
this.nativeOutput('\nUse --help for more info.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -463,4 +468,22 @@ export abstract class Directive extends AppClass {
|
|||||||
protected nativeOutput(...outputs: any[]): void {
|
protected nativeOutput(...outputs: any[]): void {
|
||||||
console.log(...outputs) // eslint-disable-line no-console
|
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
23
src/cli/decorators.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||||
|
import {CommandLine} from './service'
|
||||||
|
import {Directive} from './Directive'
|
||||||
|
import {logIfDebugging} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a class as a command-line Directive.
|
||||||
|
* The class must extend Directive.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const CLIDirective = (): ClassDecorator => {
|
||||||
|
return (target) => {
|
||||||
|
if ( isInstantiableOf(target, Directive) ) {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||||
|
ContainerBlueprint.getContainerBlueprint()
|
||||||
|
.onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
|
||||||
|
cli.registerDirective(target as Instantiable<Directive>)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ import {Directive, OptionDefinition} from '../Directive'
|
|||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {Routing} from '../../service/Routing'
|
import {Routing} from '../../service/Routing'
|
||||||
import Table = require('cli-table')
|
import Table = require('cli-table')
|
||||||
import {RouteHandler} from '../../http/routing/Route'
|
import {HTTPMethod} from '../../http/lifecycle/Request'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RouteDirective extends Directive {
|
export class RouteDirective extends Directive {
|
||||||
@ -33,39 +33,40 @@ export class RouteDirective extends Directive {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
this.routing.getCompiled()
|
const matched = this.routing.getCompiled()
|
||||||
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
|
.filter(match => {
|
||||||
.tap(matches => {
|
if ( !method ) {
|
||||||
if ( !matches.length ) {
|
return match.getRoute().trim() === route
|
||||||
this.error('No matching routes found. (Use `./ex routes` to list)')
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|
||||||
|
|| match.match(method as HTTPMethod, route)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.each(match => {
|
.some(match => {
|
||||||
const pre = match.getMiddlewares()
|
const displays = match.getDisplays()
|
||||||
.where('stage', '=', 'pre')
|
.map<[string, string]>(ware => [ware.stage, ware.display])
|
||||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
|
||||||
|
|
||||||
const post = match.getMiddlewares()
|
if ( displays.isEmpty() ) {
|
||||||
.where('stage', '=', 'post')
|
return
|
||||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
}
|
||||||
|
|
||||||
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
|
const maxLen = displays.max(x => x[1].length)
|
||||||
|
|
||||||
const table = new Table({
|
const table = new Table({
|
||||||
head: ['Stage', 'Handler'],
|
head: ['Stage', 'Handler'],
|
||||||
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
|
colWidths: [10, maxLen + 2],
|
||||||
})
|
})
|
||||||
|
|
||||||
table.push(...pre.toArray())
|
displays.each(x => table.push(x))
|
||||||
table.push(['handler', match.getDisplayableHandler()])
|
|
||||||
table.push(...post.toArray())
|
|
||||||
|
|
||||||
this.info(`\nRoute: ${match}\n\n${table}`)
|
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
protected handlerToString(handler: RouteHandler): string {
|
if ( !matched ) {
|
||||||
return typeof handler === 'string' ? handler : '(anonymous function)'
|
this.error('No matching routes found.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,21 @@ export class RoutesDirective extends Directive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handle(): Promise<void> {
|
async handle(): Promise<void> {
|
||||||
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length)
|
const compiled = this.routing.getCompiled()
|
||||||
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
|
|
||||||
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()])
|
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({
|
const table = new Table({
|
||||||
head: ['Route', 'Handler'],
|
head: ['Route', 'Handler', 'Name'],
|
||||||
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
|
colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
|
||||||
})
|
})
|
||||||
|
|
||||||
table.push(...rows.toArray())
|
table.push(...rows.toArray())
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import {Directive} from '../Directive'
|
import {Directive, OptionDefinition} from '../Directive'
|
||||||
import * as colors from 'colors/safe'
|
import * as colors from 'colors/safe'
|
||||||
import * as repl from 'repl'
|
import * as repl from 'repl'
|
||||||
import {DependencyKey} from '../../di'
|
// import * as tsNode from 'ts-node'
|
||||||
|
import {globalRegistry} from '../../util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch an interactive REPL shell from within the application.
|
* Launch an interactive REPL shell from within the application.
|
||||||
* This is very useful for debugging and testing things during development.
|
* This is very useful for debugging and testing things during development.
|
||||||
|
*
|
||||||
|
* By default, the shell launches a TypeScript interpreter, but you can use
|
||||||
|
* the `--js` flag to get a JavaScript interpreter.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```sh
|
||||||
|
* pnpm cli -- shell
|
||||||
|
* pnpm cli -- shell --js
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export class ShellDirective extends Directive {
|
export class ShellDirective extends Directive {
|
||||||
protected options: any = {
|
protected options: any = {
|
||||||
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
|
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global and @extollo/lib using the "lib" global.`,
|
||||||
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,17 +41,57 @@ export class ShellDirective extends Directive {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--js | launch in JavaScript mode instead of TypeScript',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
async handle(): Promise<void> {
|
async handle(): Promise<void> {
|
||||||
const state: any = {
|
const state: any = {
|
||||||
|
globalRegistry,
|
||||||
app: this.app(),
|
app: this.app(),
|
||||||
lib: await import('../../index'),
|
lib: await import('../../index'),
|
||||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
exports: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>(res => {
|
await new Promise<void>(res => {
|
||||||
|
// Currently, there's no way to programmatically access the async context
|
||||||
|
// of the REPL from this directive w/o requiring the user to perform manual
|
||||||
|
// actions. So, instead, override the context on the GlobalRegistry to make
|
||||||
|
// the current one the global default.
|
||||||
|
globalRegistry.forceContextOverride()
|
||||||
|
|
||||||
|
// Create the ts-node compiler service.
|
||||||
|
// const replService = tsNode.createRepl()
|
||||||
|
// const service = tsNode.create({...replService.evalAwarePartialHost})
|
||||||
|
// replService.setService(service)
|
||||||
|
|
||||||
|
// We global these values into the REPL's state directly (using the `state` object
|
||||||
|
// above), but since we're using a separate ts-node interpreter, we need to make it
|
||||||
|
// aware of the globals using declaration syntax.
|
||||||
|
// replService.evalCode(`
|
||||||
|
// declare const lib: typeof import('@extollo/lib');
|
||||||
|
// declare const app: typeof lib['Application'];
|
||||||
|
// declare const globalRegistry: typeof lib['globalRegistry'];
|
||||||
|
// `)
|
||||||
|
|
||||||
|
// Print the welome message and start the interpreter
|
||||||
this.nativeOutput(this.options.welcome)
|
this.nativeOutput(this.options.welcome)
|
||||||
this.repl = repl.start(this.options.prompt)
|
this.repl = repl.start({
|
||||||
|
// Causes the REPL to use the ts-node interpreter service:
|
||||||
|
// eval: !this.option('js', false) ? (...args) => replService.nodeEval(...args) : undefined,
|
||||||
|
prompt: this.options.prompt,
|
||||||
|
useGlobal: true,
|
||||||
|
useColors: true,
|
||||||
|
terminal: true,
|
||||||
|
preview: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add our globals into the REPL's context
|
||||||
Object.assign(this.repl.context, state)
|
Object.assign(this.repl.context, state)
|
||||||
|
|
||||||
|
// Wait for the REPL to exit
|
||||||
this.repl.on('exit', () => res())
|
this.repl.on('exit', () => res())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
64
src/cli/directive/queue/ListenDirective.ts
Normal file
64
src/cli/directive/queue/ListenDirective.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
43
src/cli/directive/queue/WorkDirective.ts
Normal file
43
src/cli/directive/queue/WorkDirective.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,3 +11,8 @@ export * from './directive/options/PositionalOption'
|
|||||||
export * from './directive/ShellDirective'
|
export * from './directive/ShellDirective'
|
||||||
export * from './directive/TemplateDirective'
|
export * from './directive/TemplateDirective'
|
||||||
export * from './directive/UsageDirective'
|
export * from './directive/UsageDirective'
|
||||||
|
|
||||||
|
export * from './decorators'
|
||||||
|
|
||||||
|
export * from './directive/queue/ListenDirective'
|
||||||
|
export * from './directive/queue/WorkDirective'
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {Unit} from '../../lifecycle/Unit'
|
import {Unit} from '../../lifecycle/Unit'
|
||||||
import {Logging} from '../../service/Logging'
|
import {Logging} from '../../service/Logging'
|
||||||
import {Singleton, Inject} from '../../di/decorator/injection'
|
import {Singleton, Inject} from '../../di'
|
||||||
import {CommandLine} from './CommandLine'
|
import {CommandLine} from './CommandLine'
|
||||||
import {UsageDirective} from '../directive/UsageDirective'
|
import {UsageDirective} from '../directive/UsageDirective'
|
||||||
import {Directive} from '../Directive'
|
import {Directive} from '../Directive'
|
||||||
@ -9,6 +9,8 @@ import {TemplateDirective} from '../directive/TemplateDirective'
|
|||||||
import {RunDirective} from '../directive/RunDirective'
|
import {RunDirective} from '../directive/RunDirective'
|
||||||
import {RoutesDirective} from '../directive/RoutesDirective'
|
import {RoutesDirective} from '../directive/RoutesDirective'
|
||||||
import {RouteDirective} from '../directive/RouteDirective'
|
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
|
* Unit that takes the place of the final unit in the application that handles
|
||||||
@ -46,6 +48,8 @@ export class CommandLineApplication extends Unit {
|
|||||||
this.cli.registerDirective(RunDirective)
|
this.cli.registerDirective(RunDirective)
|
||||||
this.cli.registerDirective(RoutesDirective)
|
this.cli.registerDirective(RoutesDirective)
|
||||||
this.cli.registerDirective(RouteDirective)
|
this.cli.registerDirective(RouteDirective)
|
||||||
|
this.cli.registerDirective(WorkDirective)
|
||||||
|
this.cli.registerDirective(ListenDirective)
|
||||||
|
|
||||||
const argv = process.argv.slice(2)
|
const argv = process.argv.slice(2)
|
||||||
const match = this.cli.getDirectives()
|
const match = this.cli.getDirectives()
|
||||||
|
@ -1,35 +1,124 @@
|
|||||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
|
import {
|
||||||
|
DependencyKey,
|
||||||
|
InstanceRef,
|
||||||
|
Instantiable,
|
||||||
|
isInstantiable,
|
||||||
|
StaticClass,
|
||||||
|
StaticInstantiable,
|
||||||
|
TypedDependencyKey,
|
||||||
|
} from './types'
|
||||||
import {AbstractFactory} from './factory/AbstractFactory'
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
import {
|
||||||
|
Awaitable,
|
||||||
|
collect,
|
||||||
|
Collection,
|
||||||
|
globalRegistry,
|
||||||
|
hasOwnProperty,
|
||||||
|
logIfDebugging, Unsubscribe,
|
||||||
|
} from '../util'
|
||||||
|
import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
|
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
|
||||||
import {ClosureFactory} from './factory/ClosureFactory'
|
import {ClosureFactory} from './factory/ClosureFactory'
|
||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import SingletonFactory from './factory/SingletonFactory'
|
import SingletonFactory from './factory/SingletonFactory'
|
||||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||||
import {ContainerBlueprint} from './ContainerBlueprint'
|
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||||
|
|
||||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||||
export type MaybeDependency = any | undefined
|
export type MaybeDependency = any | undefined
|
||||||
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
|
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' || typeof what === 'function')
|
||||||
|
&& what !== null
|
||||||
|
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
|
||||||
|
&& what.awareOfContainerLifecycle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||||
*/
|
*/
|
||||||
export class Container {
|
export class Container {
|
||||||
|
/**
|
||||||
|
* 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))
|
||||||
|
})
|
||||||
|
|
||||||
|
container.subscribeToBlueprintChanges(ContainerBlueprint.getContainerBlueprint())
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the global instance of this container.
|
* Get the global instance of this container.
|
||||||
*/
|
*/
|
||||||
public static getContainer(): Container {
|
public static getContainer(): Container {
|
||||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||||
if ( !existing ) {
|
if ( !existing ) {
|
||||||
const container = new Container()
|
if ( this.realizingContainer ) {
|
||||||
|
throw new ErrorWithContext('Attempted getContainer call during container realization.')
|
||||||
ContainerBlueprint.getContainerBlueprint()
|
}
|
||||||
.resolve()
|
|
||||||
.map(factory => container.registerFactory(factory))
|
|
||||||
|
|
||||||
|
this.realizingContainer = true
|
||||||
|
const container = Container.realizeContainer(new Container())
|
||||||
globalRegistry.setGlobal('extollo/injector', container)
|
globalRegistry.setGlobal('extollo/injector', container)
|
||||||
|
this.realizingContainer = false
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,11 +137,51 @@ export class Container {
|
|||||||
*/
|
*/
|
||||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection of subscriptions to ContainerBlueprint events.
|
||||||
|
* We keep this around so we can remove the subscriptions when the container is destroyed.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected blueprintSubscribers: Collection<Unsubscribe> = new Collection()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.registerSingletonInstance<Container>(Container, this)
|
this.registerSingletonInstance<Container>(Container, this)
|
||||||
this.registerSingleton('injector', this)
|
this.registerSingleton('injector', this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Make the container listen to changes in the given blueprint. */
|
||||||
|
private subscribeToBlueprintChanges(blueprint: ContainerBlueprint): void {
|
||||||
|
this.blueprintSubscribers.push(
|
||||||
|
blueprint.resolve$(factory => this.registerFactory(factory())),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.blueprintSubscribers.push(
|
||||||
|
blueprint.resolveConstructable$(factoryClass => this.registerFactory(this.make(factoryClass))),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.blueprintSubscribers.push(
|
||||||
|
blueprint.resolveResolutionCallbacks$(listener => this.onResolve(listener.key).then(value => listener.callback(value))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge all factories and instances of the given key from this container.
|
* Purge all factories and instances of the given key from this container.
|
||||||
* @param key
|
* @param key
|
||||||
@ -68,7 +197,14 @@ export class Container {
|
|||||||
* @param key
|
* @param key
|
||||||
*/
|
*/
|
||||||
release(key: DependencyKey): this {
|
release(key: DependencyKey): this {
|
||||||
this.instances = this.instances.filter(x => x.key !== key)
|
this.instances = this.instances.filter(x => {
|
||||||
|
if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) {
|
||||||
|
x.value.onContainerRelease?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.key !== key
|
||||||
|
})
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +222,52 @@ export class Container {
|
|||||||
return this
|
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.
|
* Register the given function as a factory within the container.
|
||||||
* @param {string} name - unique name to identify the factory in the container
|
* @param {string} name - unique name to identify the factory in the container
|
||||||
@ -172,6 +354,26 @@ export class Container {
|
|||||||
return this.instances.where('key', '=', key).isNotEmpty()
|
return this.instances.where('key', '=', key).isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a Promise that resolves the first time the given dependency key is resolved
|
||||||
|
* by the application. If it has already been resolved, the Promise will resolve immediately.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
|
||||||
|
if ( this.hasInstance(key) ) {
|
||||||
|
return new Promise<T>(res => res(this.make<T>(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we haven't instantiated an instance with this key yet,
|
||||||
|
// so put it onto the waitlist.
|
||||||
|
return new Promise<T>(res => {
|
||||||
|
this.waitingResolveCallbacks.push({
|
||||||
|
key,
|
||||||
|
callback: (res as (t: unknown) => unknown),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the container has a factory for the given key.
|
* Returns true if the container has a factory for the given key.
|
||||||
* @param {DependencyKey} key
|
* @param {DependencyKey} key
|
||||||
@ -200,13 +402,13 @@ export class Container {
|
|||||||
if ( factory ) {
|
if ( factory ) {
|
||||||
return factory
|
return factory
|
||||||
} else {
|
} else {
|
||||||
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
|
logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the dependency key. If a singleton value for that key already exists in this container,
|
* Resolve the dependency key. If a singleton value for that key already exists in this container,
|
||||||
* return that value. Otherwise, use the factory an given parameters to produce and return the value.
|
* return that value. Otherwise, use the factory and given parameters to produce and return the value.
|
||||||
* @param {DependencyKey} key
|
* @param {DependencyKey} key
|
||||||
* @param {...any} parameters
|
* @param {...any} parameters
|
||||||
*/
|
*/
|
||||||
@ -234,6 +436,19 @@ export class Container {
|
|||||||
value: newInstance,
|
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
|
return newInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,14 +459,21 @@ export class Container {
|
|||||||
* @param {array} parameters
|
* @param {array} parameters
|
||||||
*/
|
*/
|
||||||
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
|
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
|
||||||
|
logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack)
|
||||||
|
|
||||||
// Create the dependencies for the factory
|
// Create the dependencies for the factory
|
||||||
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
||||||
const dependencies = keys.map<ResolvedDependency>(req => {
|
const dependencies = keys.map<ResolvedDependency>(req => {
|
||||||
|
return withErrorContext(() => {
|
||||||
return {
|
return {
|
||||||
paramIndex: req.paramIndex,
|
paramIndex: req.paramIndex,
|
||||||
key: req.key,
|
key: req.key,
|
||||||
resolved: this.resolveAndCreate(req.key),
|
resolved: this.resolveAndCreate(req.key),
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
producingToken: factory.getTokenName(),
|
||||||
|
constructorDependency: req,
|
||||||
|
})
|
||||||
}).sortBy('paramIndex')
|
}).sortBy('paramIndex')
|
||||||
|
|
||||||
// Build the arguments for the factory, using dependencies in the
|
// Build the arguments for the factory, using dependencies in the
|
||||||
@ -271,12 +493,23 @@ export class Container {
|
|||||||
// Produce a new instance
|
// Produce a new instance
|
||||||
const inst = factory.produce(constructorArguments, params.reverse().all())
|
const inst = factory.produce(constructorArguments, params.reverse().all())
|
||||||
|
|
||||||
|
logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory)
|
||||||
factory.getInjectedProperties().each(dependency => {
|
factory.getInjectedProperties().each(dependency => {
|
||||||
|
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
|
||||||
if ( dependency.key && inst ) {
|
if ( dependency.key && inst ) {
|
||||||
|
withErrorContext(() => {
|
||||||
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||||
|
}, {
|
||||||
|
producingToken: factory.getTokenName(),
|
||||||
|
propertyDependency: dependency,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if ( isAwareOfContainerLifecycle(inst) ) {
|
||||||
|
this.waitingLifecycleCallbacks.push(new WeakRef<AwareOfContainerLifecycle>(inst))
|
||||||
|
}
|
||||||
|
|
||||||
return inst
|
return inst
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,13 +523,109 @@ export class Container {
|
|||||||
* @param {...any} parameters
|
* @param {...any} parameters
|
||||||
*/
|
*/
|
||||||
make<T>(target: DependencyKey, ...parameters: any[]): T {
|
make<T>(target: DependencyKey, ...parameters: any[]): T {
|
||||||
|
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 {
|
||||||
|
const result = withErrorContext(() => {
|
||||||
if (this.hasKey(target)) {
|
if (this.hasKey(target)) {
|
||||||
return this.resolveAndCreate(target, ...parameters)
|
const realized = this.resolveAndCreate(target, ...parameters)
|
||||||
|
Container.makeStack?.pop()
|
||||||
|
return realized
|
||||||
} else if (typeof target !== 'string' && isInstantiable(target)) {
|
} else if (typeof target !== 'string' && isInstantiable(target)) {
|
||||||
return this.produceFactory(new Factory(target), parameters)
|
const realized = this.produceFactory(new Factory(target), parameters)
|
||||||
} else {
|
Container.makeStack?.pop()
|
||||||
|
return realized
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if ( result ) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
Container.makeStack.pop()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
Container.makeStack.pop()
|
||||||
throw new TypeError(`Invalid or unknown make target: ${target}`)
|
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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -313,6 +642,23 @@ export class Container {
|
|||||||
return factory.getDependencyKeys().pluck('key')
|
return factory.getDependencyKeys().pluck('key')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform any cleanup necessary to destroy this container instance.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.blueprintSubscribers.mapCall('unsubscribe')
|
||||||
|
|
||||||
|
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.
|
* Given a different container, copy the factories and instances from this container over to it.
|
||||||
* @param container
|
* @param container
|
||||||
|
@ -1,8 +1,21 @@
|
|||||||
import {Instantiable} from './types'
|
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||||
import NamedFactory from './factory/NamedFactory'
|
import NamedFactory from './factory/NamedFactory'
|
||||||
import {AbstractFactory} from './factory/AbstractFactory'
|
import {AbstractFactory} from './factory/AbstractFactory'
|
||||||
import {Factory} from './factory/Factory'
|
import {Factory} from './factory/Factory'
|
||||||
|
import {ClosureFactory} from './factory/ClosureFactory'
|
||||||
|
import {Collection, collect} from '../util/collection/Collection'
|
||||||
|
import {Subscription, Unsubscribe} from '../util/support/BehaviorSubject'
|
||||||
|
|
||||||
|
/** Simple type alias for a callback to a container's onResolve method. */
|
||||||
|
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint for newly-created containers.
|
||||||
|
*
|
||||||
|
* This is used to allow global helpers like `@Singleton()`
|
||||||
|
* or `@CLIDirective()` while still supporting multiple
|
||||||
|
* global Container instances at once.
|
||||||
|
*/
|
||||||
export class ContainerBlueprint {
|
export class ContainerBlueprint {
|
||||||
private static instance?: ContainerBlueprint
|
private static instance?: ContainerBlueprint
|
||||||
|
|
||||||
@ -14,7 +27,20 @@ export class ContainerBlueprint {
|
|||||||
return this.instance
|
return this.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
protected factories: (() => AbstractFactory<any>)[] = []
|
protected factories: Collection<(() => AbstractFactory<any>)> = collect()
|
||||||
|
|
||||||
|
protected constructableFactories: Collection<StaticClass<AbstractFactory<any>, any>> = collect()
|
||||||
|
|
||||||
|
protected resolutionCallbacks: Collection<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}> = collect()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register some factory class with the container. Should take no construction params.
|
||||||
|
* @param factory
|
||||||
|
*/
|
||||||
|
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
|
||||||
|
this.constructableFactories.push(factory)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a basic instantiable class as a standard Factory with this container,
|
* Register a basic instantiable class as a standard Factory with this container,
|
||||||
@ -36,7 +62,75 @@ export class ContainerBlueprint {
|
|||||||
return this
|
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>[] {
|
resolve(): AbstractFactory<any>[] {
|
||||||
return this.factories.map(x => x())
|
return this.factories.map(x => x()).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to new factories being registered.
|
||||||
|
* Used by `Container` implementations to listen for factories being registered after the container is realized.
|
||||||
|
* @param sub
|
||||||
|
*/
|
||||||
|
resolve$(sub: Subscription<() => AbstractFactory<any>>): Unsubscribe {
|
||||||
|
return this.factories.push$(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to new constructable factories being registered.
|
||||||
|
* Used by `Container` implementations to listen for factories registered after the container is realized.
|
||||||
|
* @param sub
|
||||||
|
*/
|
||||||
|
resolveConstructable$(sub: Subscription<StaticClass<AbstractFactory<any>, any>>): Unsubscribe {
|
||||||
|
return this.constructableFactories.push$(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of DependencyKey-callback pairs to register with new containers.
|
||||||
|
*/
|
||||||
|
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
|
||||||
|
return this.resolutionCallbacks.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to new resolution callbacks being registered.
|
||||||
|
* Used by `Container` implementations to listen for callbacks registered after the container is realized.
|
||||||
|
* @param sub
|
||||||
|
*/
|
||||||
|
resolveResolutionCallbacks$(sub: Subscription<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}>): Unsubscribe {
|
||||||
|
return this.resolutionCallbacks.push$(sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
37
src/di/InjectionAware.ts
Normal file
37
src/di/InjectionAware.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
11
src/di/constructable.ts
Normal file
11
src/di/constructable.ts
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
9
src/di/decorator/getPropertyInjectionMetadata.ts
Normal file
9
src/di/decorator/getPropertyInjectionMetadata.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import {Instantiable, PropertyDependency} from '../types'
|
||||||
|
import {Collection, logIfDebugging} from '../../util'
|
||||||
|
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
|
||||||
|
|
||||||
|
export function getPropertyInjectionMetadata(token: Instantiable<any>): Collection<PropertyDependency> {
|
||||||
|
const loadedMeta = ((token as any)[propertyInjectionMetadata] || new Collection()) as Collection<PropertyDependency>
|
||||||
|
logIfDebugging('extollo.di.injection', 'getPropertyInjectionMetadata() target:', token, 'loaded:', loadedMeta)
|
||||||
|
return loadedMeta
|
||||||
|
}
|
@ -1,16 +1,17 @@
|
|||||||
import 'reflect-metadata'
|
import 'reflect-metadata'
|
||||||
import {collect, Collection} from '../../util'
|
import {collect, Collection} from '../../util/collection/Collection'
|
||||||
|
import {logIfDebugging} from '../../util/support/debug'
|
||||||
import {
|
import {
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||||
DependencyKey,
|
DependencyKey,
|
||||||
DependencyRequirement,
|
DependencyRequirement,
|
||||||
DEPENDENCY_KEYS_METADATA_KEY,
|
|
||||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
|
||||||
isInstantiable,
|
|
||||||
InjectionType,
|
InjectionType,
|
||||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
isInstantiable,
|
||||||
PropertyDependency,
|
PropertyDependency,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import {ContainerBlueprint} from '../ContainerBlueprint'
|
import {ContainerBlueprint} from '../ContainerBlueprint'
|
||||||
|
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a collection of dependency requirements for the given target object.
|
* Get a collection of dependency requirements for the given target object.
|
||||||
@ -66,19 +67,38 @@ export const Injectable = (): ClassDecorator => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the given class property to be injected by the container.
|
* Mark the given class property to be injected by the container.
|
||||||
* If a `key` is specified, that DependencyKey will be injected.
|
* If a `key` is specified, that DependencyKey will be injected.
|
||||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||||
* @param key
|
* @param key
|
||||||
|
* @param debug
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||||
return (target, property) => {
|
return (target, property) => {
|
||||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
if ( !target?.constructor ) {
|
||||||
|
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject(): target has no constructor', target)
|
||||||
|
throw new Error('Unable to define property injection: target has no constructor. Enable `extollo.di.decoration` logging to debug')
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyTarget = target.constructor
|
||||||
|
|
||||||
|
// let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyTarget) as Collection<PropertyDependency>
|
||||||
|
// Okay, this is a little fucky. We can't use Reflect's metadata capabilities because we need to write the metadata to
|
||||||
|
// the constructor, not the `target`. Because Reflect is using the prototype to store data, defining a metadata key on the constructor
|
||||||
|
// will define it for its parent constructors as well.
|
||||||
|
// So, if you have class A, class B extends A, and class C extends A, the properties for B and C will be defined on A, causing
|
||||||
|
// BOTH B and C's properties to be injected on any class extending A.
|
||||||
|
// To get around this, we instead define a custom property on the constructor itself, then use hasOwnProperty to make sure we're not
|
||||||
|
// getting the one for the parent class via the prototype chain.
|
||||||
|
let propertyMetadata = Object.prototype.hasOwnProperty.call(propertyTarget, propertyInjectionMetadata) ?
|
||||||
|
(propertyTarget as any)[propertyInjectionMetadata] as Collection<PropertyDependency> : undefined
|
||||||
|
|
||||||
if ( !propertyMetadata ) {
|
if ( !propertyMetadata ) {
|
||||||
propertyMetadata = new Collection<PropertyDependency>()
|
propertyMetadata = new Collection<PropertyDependency>()
|
||||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = Reflect.getMetadata('design:type', target, property)
|
const type = Reflect.getMetadata('design:type', target, property)
|
||||||
@ -91,12 +111,17 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
|||||||
if ( existing ) {
|
if ( existing ) {
|
||||||
existing.key = key
|
existing.key = key
|
||||||
} else {
|
} else {
|
||||||
propertyMetadata.push({ property,
|
propertyMetadata.push({
|
||||||
key })
|
property,
|
||||||
|
key,
|
||||||
|
debug,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||||
|
|
||||||
|
;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +166,7 @@ export const Singleton = (name?: string): ClassDecorator => {
|
|||||||
...(name ? { name } : {}),
|
...(name ? { name } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logIfDebugging('extollo.di.singleton', 'Registering singleton target:', target, 'injectionType:', injectionType)
|
||||||
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
|
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
|
||||||
Injectable()(target)
|
Injectable()(target)
|
||||||
|
|
||||||
@ -152,3 +178,16 @@ export const Singleton = (name?: string): ClassDecorator => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2
src/di/decorator/propertyInjectionMetadata.ts
Normal file
2
src/di/decorator/propertyInjectionMetadata.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export const propertyInjectionMetadata = Symbol('@extollo/lib:propertyInjectionMetadata')
|
@ -1,11 +1,12 @@
|
|||||||
import {DependencyKey} from '../types'
|
import {DependencyKey} from '../types'
|
||||||
|
import {ErrorWithContext} from '../../util/error/ErrorWithContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
||||||
* @extends Error
|
* @extends Error
|
||||||
*/
|
*/
|
||||||
export class InvalidDependencyKeyError extends Error {
|
export class InvalidDependencyKeyError extends ErrorWithContext {
|
||||||
constructor(key: DependencyKey) {
|
constructor(key: DependencyKey, context: {[key: string]: any} = {}) {
|
||||||
super(`No such dependency is registered with this container: ${key}`)
|
super(`No such dependency is registered with this container: ${key}`, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
|
import {DependencyKey, DependencyRequirement, Instantiable, PropertyDependency} from '../types'
|
||||||
import { Collection } from '../../util'
|
import {Collection, logIfDebugging} from '../../util'
|
||||||
|
import {getPropertyInjectionMetadata} from '../decorator/getPropertyInjectionMetadata'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for dependency container factories.
|
* Abstract base class for dependency container factories.
|
||||||
@ -41,4 +42,32 @@ export abstract class AbstractFactory<T> {
|
|||||||
* @return Collection<PropertyDependency>
|
* @return Collection<PropertyDependency>
|
||||||
*/
|
*/
|
||||||
abstract getInjectedProperties(): Collection<PropertyDependency>
|
abstract getInjectedProperties(): Collection<PropertyDependency>
|
||||||
|
|
||||||
|
/** Helper method that returns all `@Inject()`'ed properties for a token and its prototypical ancestors. */
|
||||||
|
protected getInjectedPropertiesForPrototypeChain(token: Instantiable<any>): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = getPropertyInjectionMetadata(token)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
token = Object.getPrototypeOf(token)
|
||||||
|
logIfDebugging('extollo.di.injection', 'next currentToken:', token)
|
||||||
|
} while (token !== Function.prototype && token !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable name of the token this factory produces.
|
||||||
|
* This is meant for debugging output only.
|
||||||
|
*/
|
||||||
|
public getTokenName(): string {
|
||||||
|
if ( typeof this.token === 'string' ) {
|
||||||
|
return this.token
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.token?.name ?? '(unknown token)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
84
src/di/factory/ConfiguredSingletonFactory.ts
Normal file
84
src/di/factory/ConfiguredSingletonFactory.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import {AbstractFactory} from './AbstractFactory'
|
||||||
|
import {Inject, Injectable} from '../decorator/injection'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Config} from '../../service/Config'
|
||||||
|
import {
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DependencyRequirement,
|
||||||
|
Instantiable,
|
||||||
|
isInstantiable,
|
||||||
|
PropertyDependency,
|
||||||
|
} from '../types'
|
||||||
|
import {Collection, ErrorWithContext, Maybe} from '../../util'
|
||||||
|
import 'reflect-metadata'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export abstract class ConfiguredSingletonFactory<T> extends AbstractFactory<T> {
|
||||||
|
protected static loggedDefaultImplementationWarningOnce = false
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getConfigKey(): string
|
||||||
|
|
||||||
|
protected abstract getDefaultImplementation(): Instantiable<T>
|
||||||
|
|
||||||
|
protected abstract getAbstractImplementation(): any
|
||||||
|
|
||||||
|
protected getDefaultImplementationWarning(): Maybe<string> {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(dependencies: any[], parameters: any[]): T {
|
||||||
|
return new (this.getImplementation())(...dependencies, ...parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === this.getAbstractImplementation()
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getImplementation())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
return this.getInjectedPropertiesForPrototypeChain(this.getImplementation())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getImplementation(): Instantiable<T> {
|
||||||
|
const ctor = this.constructor as typeof ConfiguredSingletonFactory
|
||||||
|
const ImplementationClass = this.config.get(this.getConfigKey(), this.getDefaultImplementation())
|
||||||
|
if ( ImplementationClass === this.getDefaultImplementation() ) {
|
||||||
|
const warning = this.getDefaultImplementationWarning()
|
||||||
|
if ( warning && !ctor.loggedDefaultImplementationWarningOnce ) {
|
||||||
|
this.logging.warn(warning)
|
||||||
|
ctor.loggedDefaultImplementationWarningOnce = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isInstantiable(ImplementationClass)
|
||||||
|
|| !(ImplementationClass.prototype instanceof this.getAbstractImplementation())
|
||||||
|
) {
|
||||||
|
throw new ErrorWithContext('Configured service clas does not properly extend from implementation base class.', {
|
||||||
|
configKey: this.getConfigKey(),
|
||||||
|
class: `${ImplementationClass}`,
|
||||||
|
mustExtendBase: `${this.getAbstractImplementation()}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImplementationClass as Instantiable<T>
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
import {AbstractFactory} from './AbstractFactory'
|
import {AbstractFactory} from './AbstractFactory'
|
||||||
import {
|
import {
|
||||||
DEPENDENCY_KEYS_METADATA_KEY,
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
|
||||||
DependencyRequirement,
|
DependencyRequirement,
|
||||||
Instantiable,
|
Instantiable,
|
||||||
PropertyDependency,
|
PropertyDependency,
|
||||||
@ -53,17 +52,6 @@ export class Factory<T> extends AbstractFactory<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getInjectedProperties(): Collection<PropertyDependency> {
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
const meta = new Collection<PropertyDependency>()
|
return this.getInjectedPropertiesForPrototypeChain(this.token)
|
||||||
let currentToken = this.token
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,13 @@ export * from './factory/Factory'
|
|||||||
export * from './factory/NamedFactory'
|
export * from './factory/NamedFactory'
|
||||||
export * from './factory/SingletonFactory'
|
export * from './factory/SingletonFactory'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
export * from './ContainerBlueprint'
|
export * from './ContainerBlueprint'
|
||||||
|
export * from './decorator/getPropertyInjectionMetadata'
|
||||||
|
export * from './decorator/injection'
|
||||||
|
|
||||||
export * from './Container'
|
export * from './Container'
|
||||||
export * from './ScopedContainer'
|
export * from './ScopedContainer'
|
||||||
export * from './types'
|
|
||||||
|
|
||||||
export * from './decorator/injection'
|
export * from './InjectionAware'
|
||||||
|
export * from './constructable'
|
||||||
|
@ -23,10 +23,25 @@ export function isInstantiable<T>(what: unknown): what is Instantiable<T> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given value is instantiable and, once instantiated,
|
||||||
|
* will create an instance of the given static class.
|
||||||
|
* @param what
|
||||||
|
* @param type
|
||||||
|
*/
|
||||||
|
export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): what is Instantiable<T> {
|
||||||
|
return isInstantiable(what) && what.prototype instanceof type
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||||
*/
|
*/
|
||||||
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[]> = Function & {prototype: T} & { new (...args: TCtorParams) : T } & T2 // eslint-disable-line @typescript-eslint/ban-types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Returns true if the parameter is a static class.
|
||||||
@ -41,6 +56,11 @@ export function isStaticClass<T, T2>(something: unknown): something is StaticCla
|
|||||||
*/
|
*/
|
||||||
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
|
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DependencyKey, but typed
|
||||||
|
*/
|
||||||
|
export type TypedDependencyKey<T> = Instantiable<T> | StaticClass<T, any> | string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface used to store dependency requirements by their place in the injectable
|
* Interface used to store dependency requirements by their place in the injectable
|
||||||
* target's parameters.
|
* target's parameters.
|
||||||
@ -58,6 +78,7 @@ export interface DependencyRequirement {
|
|||||||
export interface PropertyDependency {
|
export interface PropertyDependency {
|
||||||
key: DependencyKey,
|
key: DependencyKey,
|
||||||
property: string | symbol,
|
property: string | symbol,
|
||||||
|
debug?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import {Dispatchable} from './types'
|
|
||||||
import {JSONState} from '../util'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract class representing an event that may be fired.
|
|
||||||
*/
|
|
||||||
export abstract class Event implements Dispatchable {
|
|
||||||
abstract dehydrate(): Promise<JSONState>
|
|
||||||
|
|
||||||
abstract rehydrate(state: JSONState): void | Promise<void>
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import {Singleton, StaticClass} from '../di'
|
|
||||||
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
|
|
||||||
import {Awaitable, Collection, uuid4} from '../util'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
|
||||||
*/
|
|
||||||
@Singleton()
|
|
||||||
export class EventBus implements Bus {
|
|
||||||
/**
|
|
||||||
* Collection of subscribers, by their events.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
|
||||||
|
|
||||||
subscribe<T extends Dispatchable>(event: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
|
||||||
const entry: EventSubscriberEntry<T> = {
|
|
||||||
id: uuid4(),
|
|
||||||
event,
|
|
||||||
subscriber,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscribers.push(entry)
|
|
||||||
return this.buildSubscription(entry.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
|
|
||||||
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispatch(event: Dispatchable): Promise<void> {
|
|
||||||
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
|
||||||
await this.subscribers.where('event', '=', eventClass)
|
|
||||||
.promiseMap(entry => entry.subscriber(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an EventSubscription object for the subscriber of the given ID.
|
|
||||||
* @param id
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected buildSubscription(id: string): EventSubscription {
|
|
||||||
let subscribed = true
|
|
||||||
return {
|
|
||||||
unsubscribe: (): Awaitable<void> => {
|
|
||||||
if ( subscribed ) {
|
|
||||||
this.subscribers = this.subscribers.where('id', '!=', id)
|
|
||||||
subscribed = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import {EventBus} from './EventBus'
|
|
||||||
import {Collection} from '../util'
|
|
||||||
import {Bus, Dispatchable} from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
|
||||||
* This bus also supports "propagating" events along to any other connected buses.
|
|
||||||
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
|
|
||||||
* level bus whose events still reach the global EventBus instance.
|
|
||||||
*/
|
|
||||||
export class PropagatingEventBus extends EventBus {
|
|
||||||
protected recipients: Collection<Bus> = new Collection<Bus>()
|
|
||||||
|
|
||||||
async dispatch(event: Dispatchable): Promise<void> {
|
|
||||||
await super.dispatch(event)
|
|
||||||
await this.recipients.promiseMap(bus => bus.dispatch(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the given bus to receive events fired on this bus.
|
|
||||||
* @param recipient
|
|
||||||
*/
|
|
||||||
connect(recipient: Bus): void {
|
|
||||||
if ( !this.recipients.includes(recipient) ) {
|
|
||||||
this.recipients.push(recipient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
import {Awaitable, Rehydratable} from '../util'
|
|
||||||
import {StaticClass} from '../di'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A closure that should be executed with the given event is fired.
|
|
||||||
*/
|
|
||||||
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object used to track event subscriptions internally.
|
|
||||||
*/
|
|
||||||
export interface EventSubscriberEntry<T extends Dispatchable> {
|
|
||||||
/** Globally unique ID of this subscription. */
|
|
||||||
id: string
|
|
||||||
|
|
||||||
/** The event class subscribed to. */
|
|
||||||
event: StaticClass<T, T>
|
|
||||||
|
|
||||||
/** The closure to execute when the event is fired. */
|
|
||||||
subscriber: EventSubscriber<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object returned upon subscription, used to unsubscribe.
|
|
||||||
*/
|
|
||||||
export interface EventSubscription {
|
|
||||||
/**
|
|
||||||
* Unsubscribe the associated listener from the event bus.
|
|
||||||
*/
|
|
||||||
unsubscribe(): Awaitable<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An instance of something that can be fired on an event bus.
|
|
||||||
*/
|
|
||||||
export interface Dispatchable extends Rehydratable {
|
|
||||||
shouldQueue?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An event-driven bus that manages subscribers and dispatched items.
|
|
||||||
*/
|
|
||||||
export interface Bus {
|
|
||||||
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
|
||||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
|
||||||
dispatch(event: Dispatchable): Awaitable<void>
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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'
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
|
||||||
}
|
|
@ -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,
|
|
||||||
}
|
|
@ -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,
|
|
||||||
}
|
|
@ -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,
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
@ -1,5 +0,0 @@
|
|||||||
export { Arr } from './arrays'
|
|
||||||
export { Cast } from './inference'
|
|
||||||
export { Num } from './numeric'
|
|
||||||
export { Is } from './presence'
|
|
||||||
export { Str } from './strings'
|
|
@ -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,
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user