Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
|||
|
a69c81ed35
|
|||
|
36b451c32b
|
|||
|
9796a7277e
|
|||
|
f00233d49a
|
|||
|
91abcdf8ef
|
|||
|
c264d45927
|
|||
|
61731c4ebd
|
|||
|
dab3d006c8
|
|||
|
cd9bec7c5e
|
|||
|
0b86d796e8
|
|||
|
1d5056b753
|
|||
|
82e7a1f299
|
|||
|
4849016784
|
|||
|
0dde436b4c
|
|||
|
4d39637f30
|
|||
|
9be9c44a32
|
|||
|
26d54033af
|
|||
|
574ddbe9cb
|
|||
|
aca4c8aa4d
|
109
.drone.yml
@@ -1,3 +1,86 @@
|
||||
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/storage/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/storage/static/sites/extollo
|
||||
- rm -rf docs
|
||||
- tar xzf extollo_api_documentation.tar.gz
|
||||
- rm -rf extollo_api_documentation.tar.gz
|
||||
- mv www docs
|
||||
when:
|
||||
event: promote
|
||||
target: docs
|
||||
|
||||
# =============== BUILD NOTIFICATIONS ===============
|
||||
- name: send build success notifications
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls:
|
||||
from_secret: notify_webhook_url
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
|
||||
"message": "Build & deploy completed successfully.",
|
||||
"priority": 4
|
||||
}
|
||||
when:
|
||||
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
|
||||
name: default
|
||||
type: docker
|
||||
@@ -20,10 +103,19 @@ steps:
|
||||
event:
|
||||
exclude: tag
|
||||
|
||||
- name: build module
|
||||
- name: Install dependencies
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm i
|
||||
|
||||
- name: Lint code
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm lint
|
||||
|
||||
- name: build module
|
||||
image: glmdev/node-pnpm:latest
|
||||
commands:
|
||||
- pnpm build
|
||||
- mkdir artifacts
|
||||
- tar czf artifacts/extollo-lib.tar.gz lib
|
||||
@@ -134,18 +226,3 @@ steps:
|
||||
when:
|
||||
status: failure
|
||||
event: pull_request
|
||||
|
||||
- name: trigger documentation build
|
||||
image: plugins/downstream
|
||||
settings:
|
||||
server: https://ci.garrettmills.dev
|
||||
token:
|
||||
from_secret: drone_token
|
||||
fork: false
|
||||
last_successful: true
|
||||
deploy: production
|
||||
repositories:
|
||||
- Extollo/docs@master
|
||||
when:
|
||||
status: success
|
||||
event: tag
|
||||
|
||||
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
lib
|
||||
dist
|
||||
113
.eslintrc.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"no-console": "error",
|
||||
"curly": "error",
|
||||
"eqeqeq": "error",
|
||||
"guard-for-in": "error",
|
||||
"no-alert": "error",
|
||||
"no-caller": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-eval": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-invalid-this": "error",
|
||||
"no-return-await": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-useless-call": "error",
|
||||
"radix": "error",
|
||||
"yoda": "error",
|
||||
"@typescript-eslint/no-shadow": "error",
|
||||
"brace-style": "error",
|
||||
"camelcase": "error",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
"always-multiline"
|
||||
],
|
||||
"comma-spacing": [
|
||||
"error",
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"comma-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"computed-property-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"eol-last": "error",
|
||||
"func-call-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"keyword-spacing": [
|
||||
"error",
|
||||
{
|
||||
"before": true,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"lines-between-class-members": "error",
|
||||
"max-params": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"new-parens": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"newline-per-chained-call": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-underscore-dangle": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-whitespace-before-property": "error",
|
||||
"object-property-newline": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"spaced-comment": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"prefer-const": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
55
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,55 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<editorconfig>
|
||||
<option name="ENABLED" value="false" />
|
||||
</editorconfig>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="PHP">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
<option name="SMART_TABS" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="4" />
|
||||
<option name="TAB_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||
<option name="IF_BRACE_FORCE" value="3" />
|
||||
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||
<option name="FOR_BRACE_FORCE" value="3" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
19
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||
<driver-ref>mongo</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/lib.iml
generated
@@ -4,5 +4,6 @@
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="module" module-name="extollo" />
|
||||
</component>
|
||||
</module>
|
||||
1
.idea/modules.xml
generated
@@ -2,6 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
|
||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
1
docs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
www*
|
||||
31
docs/HOME.md
Normal file
@@ -0,0 +1,31 @@
|
||||
<center>
|
||||
<br>
|
||||
<img alt="The Extollo logo" src="https://static.garrettmills.dev/sites/extollo/docs/assets/logo/svg/Extollo-Icon-and-Text-LIGHT-Final.svg" height="150">
|
||||
<br><br>
|
||||
<b>extollo</b> - (v. <em>latin</em>) - to lift up, to elevate
|
||||
<br><br>
|
||||
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
|
||||
</center>
|
||||
<hr>
|
||||
|
||||
Built on principles of modularity, strict-typing, inversion-of-control, and developer ergonomics, Extollo enables developers to build maintainable, scalable, and expressive applications.
|
||||
|
||||
Node.js provides an excellent platform for quickly getting an application up and running, but this loose minimalism can lead to larger, more unweildy code-bases as your application grows. Extollo fixes this by providing an opinionated, robust framework and first-party modules that provide, among other things:
|
||||
|
||||
- Type-based dependency injection
|
||||
- Strongly-typed ORM with an expressive query-builder and models
|
||||
- Customizable session & caching interfaces
|
||||
- Modular, pre-compiled, nest-able routes
|
||||
- First-party, extensible command line tools
|
||||
- Unit-based application structure
|
||||
|
||||
## Getting Started
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Contributing
|
||||
Have an improvement or fix to Extollo? Contributors are always welcome. See the CONTRIBUTING.md file for next steps.
|
||||
1
docs/pages/About-Extollo.md
Normal file
@@ -0,0 +1 @@
|
||||
# About the Extollo Project
|
||||
7
docs/pages/Getting-Started.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Getting Started with Extollo
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js v14 or later
|
||||
- [PNPM](https://pnpm.js.org/) (not NPM/Yarn)
|
||||
- Postgres credentials (if you want to use [@extollo/orm](../modules/orm_src.html))
|
||||
6
docs/sourcefile-map.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"pattern": "^",
|
||||
"replace": "https://code.garrettmills.dev/extollo/lib/src/branch/master/"
|
||||
}
|
||||
]
|
||||
BIN
docs/static/favicon.ico
vendored
Normal file
|
After Width: | Height: | Size: 206 KiB |
19
docs/static/humans.txt
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/* PROJECT */
|
||||
|
||||
Site Name: The Extollo Framework
|
||||
Site URL: https://extollo.garrettmills.dev/
|
||||
Created: 2021/03/24
|
||||
Standards: HTML5, CSS3
|
||||
Software: TypeDoc
|
||||
|
||||
/* AUTHOR */
|
||||
|
||||
Name: Garrett Mills
|
||||
Location: Lawrence, Kansas
|
||||
Site: https://garrettmills.dev/
|
||||
Blog: https://garrettmills.dev/blog/
|
||||
Contact: https://garrettmills.dev/#contact
|
||||
|
||||
/* THANKS */
|
||||
|
||||
To Piper Mills for the excellent font, color, and logo design.
|
||||
2727
docs/theme/assets/css/main.css
vendored
Normal file
64
docs/theme/assets/css/pages.css
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
h2 code {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h3 code {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.tsd-navigation.primary ul {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tsd-navigation.primary li {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.tsd-navigation li.label.pp-nav.pp-group:first-child span {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.tsd-navigation li.label.pp-nav.pp-group {
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.tsd-navigation li.label.pp-nav.pp-group span {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.tsd-navigation li.pp-nav.pp-page.current {
|
||||
background-color: #f8f8f8;
|
||||
border-left: 2px solid #222;
|
||||
}
|
||||
|
||||
.tsd-navigation li.pp-nav.pp-page.current a {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.tsd-navigation li.pp-nav.pp-page.pp-parent.pp-active {
|
||||
border-left: 2px solid #eee;
|
||||
}
|
||||
|
||||
.tsd-navigation li.pp-nav.pp-page.pp-child {
|
||||
border-left: 2px solid #eee;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.tsd-navigation li.pp-nav.pp-page.pp-child.current {
|
||||
border-left: 2px solid #222;
|
||||
}
|
||||
|
||||
.tsd-kind-page .tsd-kind-icon:before {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
content: "";
|
||||
background-image: url("../images/page-icon.svg");
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
#tsd-search .results span.parent {
|
||||
color: #b3b2b2 !important;
|
||||
}
|
||||
BIN
docs/theme/assets/font/Extatica-Bold.otf
vendored
Normal file
BIN
docs/theme/assets/font/Extatica-Regular.otf
vendored
Normal file
BIN
docs/theme/assets/images/icons.png
vendored
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
docs/theme/assets/images/icons@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
1
docs/theme/assets/images/page-icon.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#AA43FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 468 B |
BIN
docs/theme/assets/images/widgets.png
vendored
Normal file
|
After Width: | Height: | Size: 480 B |
BIN
docs/theme/assets/images/widgets@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 855 B |
1
docs/theme/assets/js/main.js
vendored
Normal file
BIN
docs/theme/assets/logo/png/Extollo-Icon-NO-TEXT-light-and-dark-Final.png
vendored
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Icon-and-Text-DARK-Final.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Icon-and-Text-LIGHT-Final.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Text-NO-ICON-Dark-final.png
vendored
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Text-NO-ICON-Light-final.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
1
docs/theme/assets/logo/svg/Extollo-Icon-NO-TEXT-light-and-dark-Final.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 336.67 432"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="39.28 359.8 202.72 4.89 336.67 303.76 280.63 303.76 194.94 129.42 98.43 359.8 39.28 359.8"/><polygon class="cls-2" points="335.04 310.28 308.57 366.31 252.54 366.31 279 310.28 335.04 310.28"/><polygon class="cls-3" points="246.02 363.06 272.49 307.02 194.94 145.7 165.56 215.83 246.02 363.06"/><polygon class="cls-3" points="194.58 0 140.1 0 0 298.88 31.13 354.92 194.58 0"/><ellipse class="cls-4" cx="170.63" cy="420.6" rx="122.95" ry="11.4"/></svg>
|
||||
|
After Width: | Height: | Size: 691 B |
1
docs/theme/assets/logo/svg/Extollo-Icon-and-Text-DARK-Final.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 646.49 291.04"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.46 242.4 136.57 3.29 226.81 204.64 189.06 204.64 131.33 87.19 66.31 242.4 26.46 242.4"/><polygon class="cls-2" points="225.72 209.03 207.89 246.79 170.13 246.79 187.96 209.03 225.72 209.03"/><polygon class="cls-3" points="165.75 244.59 183.57 206.84 131.33 98.16 111.54 145.41 165.75 244.59"/><polygon class="cls-3" points="131.09 0 94.38 0 0 201.35 20.97 239.11 131.09 0"/><ellipse class="cls-4" cx="114.95" cy="283.36" rx="82.83" ry="7.68"/><path class="cls-2" d="M290.79,131.24H344v14.32h-35.6v27.38h32.06V187.4H308.37v28.79H344v14.33H290.79Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M353.19,220.59l15.46-27-13.9-24.11V159.6h13.33l14.75,27.66,14.75-27.66h13.33v9.93L397,193.64l15.46,27v9.93H399.14L382.83,200l-16.31,30.49H353.19Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M426.93,172.94h-9.5V159.6h9.5V144h19.15L438,159.6h20v13.34H443.39V211.8a5.11,5.11,0,0,0,5.39,5.39H458v13.33H445.66q-8.37,0-13.55-5.18t-5.18-13.54Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M475,167.05q8.65-8.73,23-8.72,14.17,0,22.83,8.72T529.48,190v10.21q0,14.18-8.66,22.9T498,231.79q-14.33,0-23-8.72t-8.65-22.9V190Q466.36,175.77,475,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.83,14.83,0,0,0,513,202.44V187.69q0-6.81-4.11-10.93T498,172.65a15,15,0,0,0-11,4.11q-4.19,4.13-4.19,10.93v14.75A14.69,14.69,0,0,0,487,213.29Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M541.53,127.69H558V230.52H541.53Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M573,127.69h16.45V230.52H573Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M610,167.05q8.64-8.73,23-8.72t22.84,8.72q8.65,8.72,8.65,22.91v10.21q0,14.18-8.65,22.9T633,231.79q-14.32,0-23-8.72t-8.65-22.9V190Q601.38,175.77,610,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.8,14.8,0,0,0,648,202.44V187.69q0-6.81-4.12-10.93T633,172.65a15,15,0,0,0-11,4.11q-4.18,4.13-4.18,10.93v14.75A14.68,14.68,0,0,0,622,213.29Z" transform="translate(-18 -34.48)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
docs/theme/assets/logo/svg/Extollo-Icon-and-Text-LIGHT-Final.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 648 290.82"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.44 242.22 136.47 3.29 226.64 204.49 188.92 204.49 131.23 87.12 66.26 242.22 26.44 242.22"/><polygon class="cls-2" points="225.54 208.88 207.73 246.6 170 246.6 187.82 208.88 225.54 208.88"/><polygon class="cls-3" points="165.62 244.41 183.43 206.68 131.23 98.09 111.45 145.3 165.62 244.41"/><polygon class="cls-3" points="130.99 0 94.31 0 0 201.2 20.96 238.93 130.99 0"/><ellipse class="cls-4" cx="114.87" cy="283.14" rx="82.77" ry="7.67"/><path class="cls-3" d="M292.58,131.27h53.14v14.32H310.15v27.35h32V187.4h-32v28.77h35.57v14.31H292.58Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M354.93,220.56l15.45-26.93-13.89-24.09v-9.92h13.32l14.74,27.64,14.74-27.64h13.32v9.92l-13.89,24.09,15.45,26.93v9.92H400.85L384.55,200l-16.3,30.47H354.93Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M428.62,172.94h-9.49V159.62h9.49V144h19.14l-8.08,15.59h20v13.32h-14.6v38.83a5.11,5.11,0,0,0,5.39,5.39h9.21v13.32H447.33q-8.35,0-13.53-5.17t-5.18-13.54Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M476.67,167.06q8.64-8.72,22.95-8.72t22.82,8.72q8.65,8.72,8.65,22.89v10.2q0,14.17-8.65,22.89t-22.82,8.72q-14.31,0-22.95-8.72T468,200.15V190Q468,175.78,476.67,167.06Zm12,46.2a14.84,14.84,0,0,0,11,4.18q6.81,0,10.92-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.92-4.11a15,15,0,0,0-11,4.11q-4.18,4.11-4.18,10.91v14.74A14.66,14.66,0,0,0,488.64,213.26Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M543.13,127.73h16.44V230.48H543.13Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M574.59,127.73H591V230.48H574.59Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M611.58,167.06q8.64-8.72,23-8.72t22.81,8.72Q666,175.78,666,190v10.2q0,14.17-8.65,22.89t-22.81,8.72q-14.32,0-23-8.72t-8.65-22.89V190Q602.93,175.78,611.58,167.06Zm12,46.2a14.85,14.85,0,0,0,11,4.18q6.8,0,10.91-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.91-4.11a15,15,0,0,0-11,4.11q-4.17,4.11-4.18,10.91v14.74A14.7,14.7,0,0,0,623.55,213.26Z" transform="translate(-18 -34.59)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
docs/theme/assets/logo/svg/Extollo-Text-NO-ICON-Dark-final.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#49686a;opacity:0.75;}.cls-2{fill:#f2c373;}</style></defs><ellipse class="cls-1" cx="214.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M5,9H66.44V25.53H25.29V57.17H62.34V73.89H25.29v33.29H66.44v16.56H5Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M77.09,112.26,95,81.11,78.9,53.23V41.76H94.31l17,32,17-32h15.41V53.23L127.75,81.11l17.88,31.15v11.48H130.21L111.36,88.49,92.5,123.74H77.09Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M162.34,57.17h-11V41.76h11v-18h22.14l-9.35,18h23.12V57.17H181.36v44.92a5.9,5.9,0,0,0,6.23,6.23h10.66v15.42H184q-9.68,0-15.66-6t-6-15.66Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M217.92,50.36q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.4,10.08q-16.56,0-26.56-10.08t-10-26.48V76.84Q207.92,60.44,217.92,50.36Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.62t-12.63-4.76q-7.86,0-12.7,4.76t-4.84,12.62v17A16.93,16.93,0,0,0,231.78,103.81Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M294.81,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M331.21,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M374,50.36q10-10.08,26.56-10.08,16.4,0,26.39,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.39,10.08q-16.56,0-26.56-10.08T364,88.65V76.84Q364,60.44,374,50.36Zm13.85,53.45q4.84,4.84,12.71,4.84t12.62-4.84q4.76-4.83,4.76-12.54v-17q0-7.87-4.76-12.62t-12.62-4.76q-7.87,0-12.71,4.76T383,74.22v17A17,17,0,0,0,387.85,103.81Z" transform="translate(-4.96 -4.87)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
docs/theme/assets/logo/svg/Extollo-Text-NO-ICON-Light-final.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#ffe293;}.cls-2{fill:#2e5252;}</style></defs><ellipse class="cls-1" cx="213.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M3.6,9H65.08V25.56H23.93V57.21H61V73.93h-37v33.28H65.08v16.56H3.6Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M75.73,112.3,93.61,81.14,77.54,53.27V41.79H93l17,32,17-32h15.42V53.27L126.4,81.14l17.87,31.16v11.47H128.86L110,88.52,91.15,123.77H75.73Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M161,57.21H150V41.79h11v-18h22.13l-9.34,18h23.11V57.21H180v44.92a5.9,5.9,0,0,0,6.23,6.23h10.65v15.41H182.63q-9.67,0-15.66-6t-6-15.66Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M216.57,50.4q10-10.08,26.56-10.08,16.39,0,26.39,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.39,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q206.56,60.49,216.57,50.4Zm13.85,53.45q4.83,4.84,12.71,4.84t12.62-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.63t-12.62-4.75q-7.87,0-12.71,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,230.42,103.85Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M293.45,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M329.85,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M372.64,50.4q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.4,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q362.64,60.49,372.64,50.4Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.76-4.83,4.75-12.54v-17q0-7.87-4.75-12.63T399.2,56.88q-7.87,0-12.7,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,386.5,103.85Z" transform="translate(-3.6 -4.9)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
51
docs/theme/layouts/default.hbs
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html class="default no-js">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{#ifCond model.name '==' project.name}}{{project.name}}{{else}}{{model.name}} | {{project.name}}{{/ifCond}}</title>
|
||||
<meta name="description" content="Documentation for {{project.name}}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="stylesheet" href="{{relativeURL "assets/css/main.css"}}">
|
||||
<link rel="author" href="{{relativeURL "humans.txt"}}">
|
||||
<script async src="{{relativeURL "assets/js/search.js"}}" id="search-script"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{> header}}
|
||||
|
||||
<div class="container container-main">
|
||||
<div class="row">
|
||||
<div class="col-8 col-content">
|
||||
{{{contents}}}
|
||||
</div>
|
||||
<div class="col-4 col-menu menu-sticky-wrap menu-highlight">
|
||||
<nav class="tsd-navigation primary">
|
||||
<ul>
|
||||
{{#each navigation.children}}
|
||||
{{> navigation}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="tsd-navigation secondary menu-sticky">
|
||||
<ul class="before-current">
|
||||
{{#each toc.children}}
|
||||
{{> toc.root}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{> footer}}
|
||||
|
||||
<div class="overlay"></div>
|
||||
<script src="{{relativeURL "assets/js/main.js"}}"></script>
|
||||
|
||||
{{> analytics}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
35
docs/theme/partials/footer.hbs
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<h2>Legend</h2>
|
||||
<div class="tsd-legend-group">
|
||||
{{#each legend}}
|
||||
<ul class="tsd-legend">
|
||||
{{#each .}}
|
||||
<li class="{{#compact}}{{#each classes}} {{.}}{{/each}}{{/compact}}"><span class="tsd-kind-icon">{{name}}</span></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{#unless settings.hideGenerator}}
|
||||
<div class="tsd-generator extollo-end-cap">
|
||||
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-and-Text-DARK-Final.svg"}}" style="max-height: 100px" class="svg-filter-white" alt="Extollo Logo">
|
||||
<p><b>extollo</b> (v. <em>latin</em>) - to lift up, to elevate</p>
|
||||
<p>
|
||||
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
|
||||
</p>
|
||||
<p class="list-of-links">
|
||||
<ul>
|
||||
<li><a href="{{relativeURL "/"}}">Home</a></li>
|
||||
<li><a href="{{relativeURL "pages/Documentation/Getting-Started.html"}}">Getting Started</a></li>
|
||||
<li><a href="{{relativeURL "pages/Documentation/About-Extollo.html"}}">About Extollo</a></li>
|
||||
<li><a href="https://code.garrettmills.dev/Extollo" target="_blank">Source Code</a></li>
|
||||
<li><a href="https://code.garrettmills.dev/extollo/extollo/src/branch/master/CONTRIBUTING.md" target="_blank">Contributing</a></li>
|
||||
<li><a href="https://code.garrettmills.dev/Extollo/docs" target="_blank">Build These Docs</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
{{/unless}}
|
||||
71
docs/theme/partials/header.hbs
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
<header>
|
||||
<div class="tsd-page-toolbar">
|
||||
<div class="container">
|
||||
<div class="table-wrap">
|
||||
<div class="table-cell" id="tsd-search" data-index="{{relativeURL "assets/js/search.json"}}" data-base="{{relativeURL "./"}}">
|
||||
<div class="field">
|
||||
<label for="tsd-search-field" class="tsd-widget search no-caption">Search</label>
|
||||
<input id="tsd-search-field" type="text" />
|
||||
</div>
|
||||
|
||||
<ul class="results">
|
||||
<li class="state loading">Preparing search index...</li>
|
||||
<li class="state failure">The search index is not available</li>
|
||||
</ul>
|
||||
|
||||
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-NO-TEXT-light-and-dark-Final.svg"}}" alt="Extollo Icon" class="token-logo" style="max-height: 30px; margin-bottom: -10px; padding-right: 10px;">
|
||||
<a href="{{relativeURL "index.html"}}" class="title">{{project.name}}</a>
|
||||
</div>
|
||||
|
||||
<div class="table-cell" id="tsd-widgets">
|
||||
<div id="tsd-filter">
|
||||
<a href="#" class="tsd-widget options no-caption" data-toggle="options">Options</a>
|
||||
<div class="tsd-filter-group">
|
||||
<div class="tsd-select" id="tsd-filter-visibility">
|
||||
<span class="tsd-select-label">All</span>
|
||||
<ul class="tsd-select-list">
|
||||
<li data-value="public">Public</li>
|
||||
<li data-value="protected">Public/Protected</li>
|
||||
<li data-value="private" class="selected">All</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="tsd-filter-inherited" checked />
|
||||
<label class="tsd-widget" for="tsd-filter-inherited">Inherited</label>
|
||||
|
||||
{{#unless settings.excludeExternals}}
|
||||
<input type="checkbox" id="tsd-filter-externals" checked />
|
||||
<label class="tsd-widget" for="tsd-filter-externals">Externals</label>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="#" class="tsd-widget menu no-caption" data-toggle="menu">Menu</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tsd-page-title">
|
||||
<div class="container">
|
||||
{{#if model.parent}} {{! Don't show breadcrumbs on main project page, it is the root page. !}}
|
||||
<ul class="tsd-breadcrumb">
|
||||
{{#with model}}{{> breadcrumb}}{{/with}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
<h1>{{#compact}}
|
||||
{{#ifCond model.kindString "!==" "Project" }}
|
||||
{{model.kindString}}
|
||||
{{/ifCond}}
|
||||
{{model.name}}
|
||||
{{#if model.typeParameters}}
|
||||
<
|
||||
{{#each model.typeParameters}}
|
||||
{{#if @index}}, {{/if}}
|
||||
{{name}}
|
||||
{{/each}}
|
||||
>
|
||||
{{/if}}
|
||||
{{/compact}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
3
docs/theme/templates/markdown-page.hbs
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="tsd-panel tsd-typography">
|
||||
{{#markdown}}{{{model.pagesPlugin.item.contents}}}{{/markdown}}
|
||||
</div>
|
||||
1338
package-lock.json
generated
54
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@extollo/lib",
|
||||
"version": "0.1.3",
|
||||
"version": "0.5.6",
|
||||
"description": "The framework library that lifts up your code.",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
@@ -8,25 +8,52 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extollo/di": "git+https://code.garrettmills.dev/extollo/di",
|
||||
"@extollo/util": "git+https://code.garrettmills.dev/extollo/util",
|
||||
"@atao60/fse-cli": "^0.1.6",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cli-table": "^0.3.0",
|
||||
"@types/ioredis": "^4.26.6",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/node": "^14.17.4",
|
||||
"@types/pg": "^8.6.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pug": "^2.0.4",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/ssh2": "^0.5.46",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"busboy": "^0.3.1",
|
||||
"cli-table": "^0.3.6",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"ioredis": "^4.27.6",
|
||||
"mime-types": "^2.1.31",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
"node-fetch": "^3",
|
||||
"pg": "^8.6.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"pug": "^3.0.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"ssh2": "^1.1.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.3"
|
||||
"typedoc": "^0.20.36",
|
||||
"typedoc-plugin-pages-fork": "^0.0.1",
|
||||
"typedoc-plugin-sourcefile-url": "^1.0.6",
|
||||
"typescript": "^4.2.3",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
|
||||
"app": "tsc && node lib/index.js",
|
||||
"prepare": "pnpm run build"
|
||||
"prepare": "pnpm run build",
|
||||
"docs:build": "typedoc --options typedoc.json",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint --fix . --ext .ts"
|
||||
},
|
||||
"files": [
|
||||
"lib/**/*"
|
||||
@@ -38,5 +65,16 @@
|
||||
"url": "https://code.garrettmills.dev/extollo/lib"
|
||||
},
|
||||
"author": "garrettmills <shout@garrettmills.dev>",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.0",
|
||||
"@typescript-eslint/parser": "^4.26.0",
|
||||
"eslint": "^7.27.0"
|
||||
},
|
||||
"extollo": {
|
||||
"discover": true,
|
||||
"units": {
|
||||
"discover": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
pagesconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"title": "Documentation",
|
||||
"pages": [
|
||||
{
|
||||
"title": "Getting Started",
|
||||
"source": "./docs/pages/Getting-Started.md"
|
||||
},
|
||||
{
|
||||
"title": "About Extollo",
|
||||
"source": "./docs/pages/About-Extollo.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2562
pnpm-lock.yaml
generated
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {ErrorWithContext} from '../util'
|
||||
|
||||
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||
|
||||
}
|
||||
40
src/auth/Authentication.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
||||
import {Unit} from '../lifecycle/Unit'
|
||||
import {Logging} from '../service/Logging'
|
||||
import {CanonicalResolver} from '../service/Canonical'
|
||||
import {Middleware} from '../http/routing/Middleware'
|
||||
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
||||
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||
import {Middlewares} from '../service/Middlewares'
|
||||
|
||||
/**
|
||||
* Unit class that bootstraps the authentication framework.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Authentication extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly middleware!: Middlewares
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.container()
|
||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the canonical namespace resolver for auth middleware.
|
||||
* @protected
|
||||
*/
|
||||
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
||||
return (key: string) => {
|
||||
return ({
|
||||
web: SessionAuthMiddleware,
|
||||
required: AuthRequiredMiddleware,
|
||||
guest: GuestRequiredMiddleware,
|
||||
})[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/auth/NotAuthorizedError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {HTTPError} from '../http/HTTPError'
|
||||
import {HTTPStatus} from '../util'
|
||||
|
||||
/**
|
||||
* Error thrown when a user attempts an action that they are not authorized to perform.
|
||||
*/
|
||||
export class NotAuthorizedError extends HTTPError {
|
||||
constructor(message = 'Not Authorized') {
|
||||
super(HTTPStatus.FORBIDDEN, message)
|
||||
}
|
||||
}
|
||||
151
src/auth/SecurityContext.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
private authenticatedUser?: Authenticatable
|
||||
|
||||
constructor(
|
||||
/** The repository from which to draw users. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
|
||||
/** The name of this context. */
|
||||
public readonly name: string,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Called when the context is created. Can be used by child-classes to do setup work.
|
||||
*/
|
||||
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Authenticate the given user, without persisting the authentication.
|
||||
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
|
||||
* @param user
|
||||
*/
|
||||
async authenticateOnce(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the given user and persist the authentication.
|
||||
* @param user
|
||||
*/
|
||||
async authenticate(user: Authenticatable): Promise<void> {
|
||||
this.authenticatedUser = user
|
||||
await this.persist()
|
||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user based on their credentials.
|
||||
* If the credentials are valid, the user will be authenticated, but the authentication
|
||||
* will not be persisted. That is, when the lifecycle ends, the user will be
|
||||
* unauthenticated implicitly.
|
||||
* @param credentials
|
||||
*/
|
||||
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticateOnce(user)
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate a user based on their credentials.
|
||||
* If the credentials are valid, the user will be authenticated and the
|
||||
* authentication will be persisted.
|
||||
* @param credentials
|
||||
*/
|
||||
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticate(user)
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, but do not persist the change.
|
||||
*/
|
||||
async flushOnce(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticate the current user, if one exists, and persist the change.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
const user = this.authenticatedUser
|
||||
if ( user ) {
|
||||
this.authenticatedUser = undefined
|
||||
await this.persist()
|
||||
await this.bus.dispatch(new UserFlushedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assuming a user is still authenticated in the context,
|
||||
* try to look up and fill in the user.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
const credentials = await this.getCredentials()
|
||||
this.logging.debug('resume:')
|
||||
this.logging.debug(credentials)
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the current state of the security context to whatever storage
|
||||
* medium the context's host provides.
|
||||
*/
|
||||
abstract persist(): Awaitable<void>
|
||||
|
||||
/**
|
||||
* Get the credentials for the current user from whatever storage medium
|
||||
* the context's host provides.
|
||||
*/
|
||||
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
*/
|
||||
getUser(): Maybe<Authenticatable> {
|
||||
return this.authenticatedUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
this.logging.debug('hasUser?')
|
||||
this.logging.debug(this.authenticatedUser)
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
145
src/auth/basic-ui/BasicLoginController.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Valid, ValidationError} from '../../forms'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class BasicLoginController extends Controller {
|
||||
public static routes({ enableRegistration = true } = {}): void {
|
||||
Route.group('auth', () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login')
|
||||
|
||||
Route.post('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login.attempt')
|
||||
|
||||
Route.any('logout', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogout()
|
||||
})
|
||||
.pre('@auth:required')
|
||||
.alias('@auth.logout')
|
||||
|
||||
if ( enableRegistration ) {
|
||||
Route.get('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getRegistration()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register')
|
||||
|
||||
Route.post('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptRegister()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register.attempt')
|
||||
}
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return this.getLoginView()
|
||||
}
|
||||
|
||||
public getRegistration(): ResponseFactory {
|
||||
return this.getRegistrationView()
|
||||
}
|
||||
|
||||
public async attemptLogin(): Promise<ResponseObject> {
|
||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.attempt(data)
|
||||
if ( user ) {
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
}
|
||||
|
||||
return this.getLoginView(['Invalid username/password.'])
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getLoginView(e.errors.all())
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async attemptLogout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return this.getMessageView('You have been logged out.')
|
||||
}
|
||||
|
||||
public async attemptRegister(): Promise<ResponseObject> {
|
||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.repository.createByCredentials(data)
|
||||
await this.security.authenticate(user)
|
||||
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getRegistrationView(e.errors.all())
|
||||
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
|
||||
return this.getRegistrationView(['A user with that username already exists.'])
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:login', {
|
||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:register', {
|
||||
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getMessageView(message: string): ResponseFactory {
|
||||
return view('@extollo:auth:message', {
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
20
src/auth/basic-ui/BasicLoginFormRequest.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
Str.alphaNum,
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(8),
|
||||
Str.confirmed,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/auth/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
||||
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
||||
|
||||
/**
|
||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
||||
*/
|
||||
export interface AuthenticatableRepositoryMapping {
|
||||
orm: Instantiable<ORMUserRepository>,
|
||||
}
|
||||
|
||||
/**
|
||||
* String mapping of AuthenticatableRepository implementations.
|
||||
*/
|
||||
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
|
||||
orm: ORMUserRepository,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for making the auth config type-safe.
|
||||
*/
|
||||
export interface AuthConfig {
|
||||
repositories: {
|
||||
session: keyof AuthenticatableRepositoryMapping,
|
||||
},
|
||||
sources?: {
|
||||
[key: string]: OAuth2LoginConfig,
|
||||
},
|
||||
}
|
||||
32
src/auth/contexts/SessionSecurityContext.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionSecurityContext extends SecurityContext {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
constructor(
|
||||
/** The repository from which to draw users. */
|
||||
public readonly repository: AuthenticatableRepository,
|
||||
) {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||
return {
|
||||
identifier: '',
|
||||
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
persist(): Awaitable<void> {
|
||||
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserAuthenticatedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* Event fired when a user is authenticated.
|
||||
*/
|
||||
export class UserAuthenticatedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* Event fired when a security context for a given user is resumed.
|
||||
*/
|
||||
export class UserAuthenticationResumedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
}
|
||||
27
src/auth/event/UserFlushedEvent.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Event} from '../../event/Event'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
import {Authenticatable} from '../types'
|
||||
|
||||
/**
|
||||
* Event fired when a user is unauthenticated.
|
||||
*/
|
||||
export class UserFlushedEvent extends Event {
|
||||
constructor(
|
||||
public readonly user: Authenticatable,
|
||||
public readonly context: SecurityContext,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
user: await this.user.dehydrate(),
|
||||
contextName: this.context.name,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
// TODO fill this in
|
||||
}
|
||||
}
|
||||
95
src/auth/external/oauth2/OAuth2LoginController.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Controller} from '../../../http/Controller'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {Config} from '../../../service/Config'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||
import {ErrorWithContext} from '../../../util'
|
||||
import {OAuth2Repository} from './OAuth2Repository'
|
||||
import {json} from '../../../http/response/JSONResponseFactory'
|
||||
|
||||
export interface OAuth2LoginConfig {
|
||||
name: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUrl: string,
|
||||
authorizationCodeField: string,
|
||||
tokenEndpoint: string,
|
||||
tokenEndpointMapping?: {
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
grantType?: string,
|
||||
codeKey?: string,
|
||||
},
|
||||
tokenEndpointResponseMapping?: {
|
||||
token?: string,
|
||||
expiresIn?: string,
|
||||
expiresAt?: string,
|
||||
},
|
||||
userEndpoint: string,
|
||||
userEndpointResponseMapping?: {
|
||||
identifier?: string,
|
||||
display?: string,
|
||||
},
|
||||
}
|
||||
|
||||
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
|
||||
return (
|
||||
Boolean(what)
|
||||
&& typeof (what as any).name === 'string'
|
||||
&& typeof (what as any).clientId === 'string'
|
||||
&& typeof (what as any).clientSecret === 'string'
|
||||
&& typeof (what as any).redirectUrl === 'string'
|
||||
&& typeof (what as any).authorizationCodeField === 'string'
|
||||
&& typeof (what as any).tokenEndpoint === 'string'
|
||||
&& typeof (what as any).userEndpoint === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2LoginController extends Controller {
|
||||
public static routes(configName: string): void {
|
||||
Route.group(`/auth/${configName}`, () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
|
||||
return controller.getLogin()
|
||||
}).pre('@auth:guest')
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
constructor(
|
||||
protected readonly request: Request,
|
||||
protected readonly configName: string,
|
||||
) {
|
||||
super(request)
|
||||
}
|
||||
|
||||
public async getLogin(): Promise<ResponseObject> {
|
||||
const repo = this.getRepository()
|
||||
if ( repo.shouldRedirect() ) {
|
||||
return repo.redirect()
|
||||
}
|
||||
|
||||
// We were redirected from the auth source
|
||||
const user = await repo.redeem()
|
||||
return json(user)
|
||||
}
|
||||
|
||||
protected getRepository(): OAuth2Repository {
|
||||
return this.request.make(OAuth2Repository, this.getConfig())
|
||||
}
|
||||
|
||||
protected getConfig(): OAuth2LoginConfig {
|
||||
const config = this.config.get(`auth.sources.${this.configName}`)
|
||||
if ( !isOAuth2LoginConfig(config) ) {
|
||||
throw new ErrorWithContext('Invalid OAuth2 source config.', {
|
||||
configName: this.configName,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
156
src/auth/external/oauth2/OAuth2Repository.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../../types'
|
||||
import {Inject, Injectable} from '../../../di'
|
||||
import {
|
||||
Awaitable,
|
||||
dataGetUnsafe,
|
||||
fetch,
|
||||
Maybe,
|
||||
MethodNotSupportedError,
|
||||
UniversalPath,
|
||||
universalPath,
|
||||
uuid4,
|
||||
} from '../../../util'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Session} from '../../../http/session/Session'
|
||||
import {ResponseObject} from '../../../http/routing/Route'
|
||||
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
|
||||
import {Request} from '../../../http/lifecycle/Request'
|
||||
import {Logging} from '../../../service/Logging'
|
||||
import {OAuth2User} from './OAuth2User'
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2Repository implements AuthenticatableRepository {
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
@Inject()
|
||||
protected readonly request!: Request
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor(
|
||||
protected readonly config: OAuth2LoginConfig,
|
||||
) { }
|
||||
|
||||
public createByCredentials(): Awaitable<Authenticatable> {
|
||||
throw new MethodNotSupportedError()
|
||||
}
|
||||
|
||||
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
|
||||
return this.getAuthenticatableFromBearer(credentials.credential)
|
||||
}
|
||||
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
public getRedirectUrl(state?: string): UniversalPath {
|
||||
const url = universalPath(this.config.redirectUrl)
|
||||
if ( state ) {
|
||||
url.query.append('state', state)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public getTokenEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.tokenEndpoint)
|
||||
}
|
||||
|
||||
public getUserEndpoint(): UniversalPath {
|
||||
return universalPath(this.config.userEndpoint)
|
||||
}
|
||||
|
||||
public async redeem(): Promise<Maybe<OAuth2User>> {
|
||||
if ( !this.stateIsValid() ) {
|
||||
return // FIXME throw
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
|
||||
if ( this.config.tokenEndpointMapping ) {
|
||||
if ( this.config.tokenEndpointMapping.clientId ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.clientSecret ) {
|
||||
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.codeKey ) {
|
||||
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
|
||||
}
|
||||
|
||||
if ( this.config.tokenEndpointMapping.grantType ) {
|
||||
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
|
||||
}
|
||||
}
|
||||
|
||||
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
|
||||
|
||||
const response = await fetch(this.getTokenEndpoint().toRemote, {
|
||||
method: 'post',
|
||||
body: body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
this.logging.debug(data)
|
||||
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
|
||||
|
||||
this.logging.debug(bearer)
|
||||
if ( !bearer || typeof bearer !== 'string' ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return this.getAuthenticatableFromBearer(bearer)
|
||||
}
|
||||
|
||||
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
|
||||
const response = await fetch(this.getUserEndpoint().toRemote, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${bearer}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if ( typeof data !== 'object' || data === null ) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
return new OAuth2User(data, this.config)
|
||||
}
|
||||
|
||||
public stateIsValid(): boolean {
|
||||
const correctState = this.session.get('extollo.auth.oauth2.state', '')
|
||||
const inputState = this.request.input('state') || ''
|
||||
return correctState === inputState
|
||||
}
|
||||
|
||||
public shouldRedirect(): boolean {
|
||||
const codeField = this.config.authorizationCodeField
|
||||
const code = this.request.input(codeField)
|
||||
return !code
|
||||
}
|
||||
|
||||
public async redirect(): Promise<ResponseObject> {
|
||||
const state = uuid4()
|
||||
await this.session.set('extollo.auth.oauth2.state', state)
|
||||
return temporary(this.getRedirectUrl(state).toRemote)
|
||||
}
|
||||
}
|
||||
50
src/auth/external/oauth2/OAuth2User.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
|
||||
import {OAuth2LoginConfig} from './OAuth2LoginController'
|
||||
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
|
||||
|
||||
export class OAuth2User implements Authenticatable {
|
||||
protected displayField: string
|
||||
|
||||
protected identifierField: string
|
||||
|
||||
constructor(
|
||||
protected data: {[key: string]: any},
|
||||
config: OAuth2LoginConfig,
|
||||
) {
|
||||
this.displayField = config.userEndpointResponseMapping?.display || 'name'
|
||||
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
|
||||
}
|
||||
|
||||
getDisplayIdentifier(): string {
|
||||
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
|
||||
}
|
||||
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return {
|
||||
isOAuth2User: true,
|
||||
data: this.data,
|
||||
displayField: this.displayField,
|
||||
identifierField: this.identifierField,
|
||||
}
|
||||
}
|
||||
|
||||
rehydrate(state: JSONState): Awaitable<void> {
|
||||
if (
|
||||
!state.isOAuth2User
|
||||
|| typeof state.data !== 'object'
|
||||
|| state.data === null
|
||||
|| typeof state.displayField !== 'string'
|
||||
|| typeof state.identifierField !== 'string'
|
||||
) {
|
||||
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
|
||||
}
|
||||
|
||||
this.data = state.data
|
||||
this.identifierField = state.identifierField
|
||||
this.displayField = state.identifierField
|
||||
}
|
||||
}
|
||||
26
src/auth/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export * from './types'
|
||||
export * from './NotAuthorizedError'
|
||||
|
||||
export * from './SecurityContext'
|
||||
|
||||
export * from './event/UserAuthenticatedEvent'
|
||||
export * from './event/UserFlushedEvent'
|
||||
export * from './event/UserAuthenticationResumedEvent'
|
||||
|
||||
export * from './contexts/SessionSecurityContext'
|
||||
|
||||
export * from './orm/ORMUser'
|
||||
export * from './orm/ORMUserRepository'
|
||||
|
||||
export * from './middleware/AuthRequiredMiddleware'
|
||||
export * from './middleware/GuestRequiredMiddleware'
|
||||
export * from './middleware/SessionAuthMiddleware'
|
||||
|
||||
export * from './Authentication'
|
||||
|
||||
export * from './config'
|
||||
|
||||
export * from './basic-ui/BasicLoginFormRequest'
|
||||
export * from './basic-ui/BasicLoginController'
|
||||
|
||||
export * from './external/oauth2/OAuth2LoginController'
|
||||
34
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Session} from '../../http/session/Session'
|
||||
|
||||
@Injectable()
|
||||
export class AuthRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( !this.security.hasUser() ) {
|
||||
this.session.set('auth.intention', this.request.url)
|
||||
|
||||
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {error} from '../../http/response/ErrorResponseFactory'
|
||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||
import {HTTPStatus} from '../../util'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class GuestRequiredMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
if ( this.security.hasUser() ) {
|
||||
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
|
||||
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
|
||||
} else {
|
||||
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Middleware} from '../../http/routing/Middleware'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject} from '../../http/routing/Route'
|
||||
import {Config} from '../../service/Config'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {ORMUserRepository} from '../orm/ORMUserRepository'
|
||||
import {AuthConfig, AuthenticatableRepositories} from '../config'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Injects a SessionSecurityContext into the request and attempts to
|
||||
* resume the user's authentication.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SessionAuthMiddleware extends Middleware {
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
async apply(): Promise<ResponseObject> {
|
||||
this.logging.debug('Applying session auth middleware...')
|
||||
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||
this.request.registerSingletonInstance(SecurityContext, context)
|
||||
await context.resume()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the correct AuthenticatableRepository based on the auth config.
|
||||
* @protected
|
||||
*/
|
||||
protected getRepository(): AuthenticatableRepository {
|
||||
const config: AuthConfig | undefined = this.config.get('auth')
|
||||
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
|
||||
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
|
||||
}
|
||||
}
|
||||
64
src/auth/orm/ORMUser.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {Field, FieldType, Model} from '../../orm'
|
||||
import {Authenticatable, AuthenticatableIdentifier} from '../types'
|
||||
import {Injectable} from '../../di'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import {Awaitable, JSONState} from '../../util'
|
||||
|
||||
/**
|
||||
* A basic ORM-driven user class.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
|
||||
protected static table = 'users'
|
||||
|
||||
protected static key = 'user_id'
|
||||
|
||||
/** The primary key of the user in the table. */
|
||||
@Field(FieldType.serial, 'user_id')
|
||||
public userId!: number
|
||||
|
||||
/** The unique string-identifier of the user. */
|
||||
@Field(FieldType.varchar)
|
||||
public username!: string
|
||||
|
||||
/** The user's first name. */
|
||||
@Field(FieldType.varchar, 'first_name')
|
||||
public firstName?: string
|
||||
|
||||
/** The user's last name. */
|
||||
@Field(FieldType.varchar, 'last_name')
|
||||
public lastName?: string
|
||||
|
||||
/** The hashed and salted password of the user. */
|
||||
@Field(FieldType.varchar, 'password_hash')
|
||||
public passwordHash!: string
|
||||
|
||||
/** Human-readable display name of the user. */
|
||||
getDisplayIdentifier(): string {
|
||||
return `${this.firstName} ${this.lastName}`
|
||||
}
|
||||
|
||||
/** Unique identifier of the user. */
|
||||
getIdentifier(): AuthenticatableIdentifier {
|
||||
return this.username
|
||||
}
|
||||
|
||||
/** Check if the provided password is valid for the user. */
|
||||
verifyPassword(password: string): Awaitable<boolean> {
|
||||
return bcrypt.compare(password, this.passwordHash)
|
||||
}
|
||||
|
||||
/** Change the user's password, hashing it. */
|
||||
async setPassword(password: string): Promise<void> {
|
||||
this.passwordHash = await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
async dehydrate(): Promise<JSONState> {
|
||||
return this.toQueryRow()
|
||||
}
|
||||
|
||||
async rehydrate(state: JSONState): Promise<void> {
|
||||
await this.assumeFromSource(state)
|
||||
}
|
||||
}
|
||||
65
src/auth/orm/ORMUserRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', id)
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to look up a user by the credentials provided.
|
||||
* If a securityIdentifier is specified, look up the user by username.
|
||||
* If username/password are specified, look up the user and verify the password.
|
||||
* @param credentials
|
||||
*/
|
||||
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
if ( !credentials.identifier && credentials.credential ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.credential)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.identifier && credentials.credential ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.identifier)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||
if ( await this.getByCredentials(credentials) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
identifier: credentials.identifier,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.make(ORMUser)
|
||||
user.username = credentials.identifier
|
||||
await user.setPassword(credentials.credential)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
43
src/auth/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
export interface AuthenticatableCredentials {
|
||||
identifier: string,
|
||||
credential: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
export abstract class Authenticatable implements Rehydratable {
|
||||
|
||||
/** Get the unique identifier of the user. */
|
||||
abstract getIdentifier(): AuthenticatableIdentifier
|
||||
|
||||
/** Get the human-readable identifier of the user. */
|
||||
abstract getDisplayIdentifier(): string
|
||||
|
||||
abstract dehydrate(): Promise<JSONState>
|
||||
|
||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for a repository that stores and recalls users.
|
||||
*/
|
||||
export abstract class AuthenticatableRepository {
|
||||
|
||||
/** Look up the user by their unique identifier. */
|
||||
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
/**
|
||||
* Attempt to look up and verify a user by their credentials.
|
||||
* Returns the user if the credentials are valid.
|
||||
* @param credentials
|
||||
*/
|
||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||
}
|
||||
471
src/cli/Directive.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import {Injectable, Inject} from '../di'
|
||||
import {infer, ErrorWithContext} from '../util'
|
||||
import {CLIOption} from './directive/options/CLIOption'
|
||||
import {PositionalOption} from './directive/options/PositionalOption'
|
||||
import {FlagOption} from './directive/options/FlagOption'
|
||||
import {AppClass} from '../lifecycle/AppClass'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Type alias for a definition of a command-line option.
|
||||
*
|
||||
* This can be either an instance of CLIOption or a string describing an option.
|
||||
*
|
||||
* @example
|
||||
* Some examples of positional/flag options defined by strings:
|
||||
* `'{file name} | canonical name of the resource to create'`
|
||||
*
|
||||
* `'--push -p {value} | the value to be pushed'`
|
||||
*
|
||||
* `'--force -f | do a force push'`
|
||||
*/
|
||||
export type OptionDefinition = CLIOption<any> | string
|
||||
|
||||
/**
|
||||
* An error thrown when an invalid option was detected.
|
||||
*/
|
||||
export class OptionValidationError extends ErrorWithContext {}
|
||||
|
||||
/**
|
||||
* A base class representing a sub-command in the command-line utility.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class Directive extends AppClass {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** Parsed option values. */
|
||||
private optionValues: any
|
||||
|
||||
/**
|
||||
* Get the keyword or array of keywords that will specify this directive.
|
||||
*
|
||||
* @example
|
||||
* If this returns `['up', 'start']`, the directive can be run by either of:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex up
|
||||
* ./ex start
|
||||
* ```
|
||||
*/
|
||||
public abstract getKeywords(): string | string[]
|
||||
|
||||
/**
|
||||
* Get the usage description of this directive. Should be brief (1 sentence).
|
||||
*/
|
||||
public abstract getDescription(): string
|
||||
|
||||
/**
|
||||
* Optionally, specify a longer usage text that is shown on the directive's `--help` page.
|
||||
*/
|
||||
public getHelpText(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of options defined for this command.
|
||||
*/
|
||||
public getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the directive is run from the command line.
|
||||
*
|
||||
* The raw arguments are provided as `argv`, but you are encouraged to use
|
||||
* `getOptions()` and `option()` helpers to access the parsed options instead.
|
||||
*
|
||||
* @param argv
|
||||
*/
|
||||
public abstract handle(argv: string[]): void | Promise<void>
|
||||
|
||||
/**
|
||||
* Sets the parsed option values.
|
||||
* @param optionValues
|
||||
* @private
|
||||
*/
|
||||
private setOptionValues(optionValues: any) {
|
||||
this.optionValues = optionValues
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a parsed option. If none exists, return `defaultValue`.
|
||||
* @param name
|
||||
* @param defaultValue
|
||||
*/
|
||||
public option(name: string, defaultValue?: unknown): any {
|
||||
if ( name in this.optionValues ) {
|
||||
return this.optionValues[name]
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke this directive with the specified arguments.
|
||||
*
|
||||
* If usage was requested (see `didRequestUsage()`), it prints the extended usage info.
|
||||
*
|
||||
* Otherwise, it parses the options from `argv` and calls `handle()`.
|
||||
*
|
||||
* @param argv
|
||||
*/
|
||||
async invoke(argv: string[]): Promise<void> {
|
||||
const options = this.getResolvedOptions()
|
||||
|
||||
if ( this.didRequestUsage(argv) ) {
|
||||
const positionalArguments: PositionalOption<any>[] = []
|
||||
options.forEach(opt => {
|
||||
if ( opt instanceof PositionalOption ) {
|
||||
positionalArguments.push(opt)
|
||||
}
|
||||
})
|
||||
|
||||
const flagArguments: FlagOption<any>[] = []
|
||||
options.forEach(opt => {
|
||||
if ( opt instanceof FlagOption ) {
|
||||
flagArguments.push(opt)
|
||||
}
|
||||
})
|
||||
|
||||
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
|
||||
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
|
||||
|
||||
this.nativeOutput([
|
||||
'',
|
||||
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
|
||||
'',
|
||||
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
|
||||
].join('\n'))
|
||||
|
||||
if ( positionalArguments.length ) {
|
||||
this.nativeOutput([
|
||||
'',
|
||||
`POSITIONAL ARGUMENTS:`,
|
||||
...(positionalArguments.map(arg => {
|
||||
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
|
||||
})),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
if ( flagArguments.length ) {
|
||||
this.nativeOutput([
|
||||
'',
|
||||
`FLAGS:`,
|
||||
...(flagArguments.map(arg => {
|
||||
return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}`
|
||||
})),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const help = this.getHelpText()
|
||||
if ( help ) {
|
||||
this.nativeOutput('\n' + help)
|
||||
}
|
||||
|
||||
this.nativeOutput('\n')
|
||||
} else {
|
||||
try {
|
||||
const optionValues = this.parseOptions(options, argv)
|
||||
this.setOptionValues(optionValues)
|
||||
await this.handle(argv)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof Error ) {
|
||||
this.nativeOutput(e.message)
|
||||
this.error(e)
|
||||
}
|
||||
|
||||
if ( e instanceof OptionValidationError ) {
|
||||
// expecting, value, requirements
|
||||
if ( e.context.expecting ) {
|
||||
this.nativeOutput(` - Expecting: ${e.context.expecting}`)
|
||||
}
|
||||
|
||||
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
|
||||
for ( const req of e.context.requirements ) {
|
||||
this.nativeOutput(` - ${req}`)
|
||||
}
|
||||
}
|
||||
|
||||
if ( e.context.value ) {
|
||||
this.nativeOutput(` - ${e.context.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.nativeOutput('\nUse --help for more info.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the array of option definitions to CLIOption instances.
|
||||
* Of note, this resolves the string-form definitions to actual CLIOption instances.
|
||||
*/
|
||||
public getResolvedOptions(): CLIOption<any>[] {
|
||||
return this.getOptions().map(option => {
|
||||
if ( typeof option === 'string' ) {
|
||||
return this.instantiateOptionFromString(option)
|
||||
} else {
|
||||
return option
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main keyword displayed for this directive.
|
||||
* @example
|
||||
* If `getKeywords()` returns `['up', 'start']`, this will return `'up'`.
|
||||
*/
|
||||
public getMainKeyword(): string {
|
||||
const kws = this.getKeywords()
|
||||
|
||||
if ( Array.isArray(kws) ) {
|
||||
return kws[0]
|
||||
}
|
||||
|
||||
return kws
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given keyword should invoke this directive.
|
||||
* @param name
|
||||
*/
|
||||
public matchesKeyword(name: string): boolean {
|
||||
let kws = this.getKeywords()
|
||||
if ( !Array.isArray(kws) ) {
|
||||
kws = [kws]
|
||||
}
|
||||
return kws.includes(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as success text.
|
||||
* @param output
|
||||
*/
|
||||
success(output: unknown): void {
|
||||
this.logging.success(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as error text.
|
||||
* @param output
|
||||
*/
|
||||
error(output: unknown): void {
|
||||
this.logging.error(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as warning text.
|
||||
* @param output
|
||||
*/
|
||||
warn(output: unknown): void {
|
||||
this.logging.warn(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as info text.
|
||||
* @param output
|
||||
*/
|
||||
info(output: unknown): void {
|
||||
this.logging.info(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as debugging text.
|
||||
* @param output
|
||||
*/
|
||||
debug(output: unknown): void {
|
||||
this.logging.debug(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as verbose text.
|
||||
* @param output
|
||||
*/
|
||||
verbose(output: unknown): void {
|
||||
this.logging.verbose(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flag option that signals help. Usually, this is named 'help'
|
||||
* and supports the flags '--help' and '-?'.
|
||||
*/
|
||||
getHelpOption(): FlagOption<any> {
|
||||
return new FlagOption('--help', '-?', 'usage information about this directive')
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the raw CLI arguments using an array of option class instances to build
|
||||
* a mapping of option names to provided values.
|
||||
*/
|
||||
parseOptions(options: CLIOption<any>[], args: string[]): {[key: string]: any} {
|
||||
let positionalArguments: PositionalOption<any>[] = []
|
||||
options.forEach(opt => {
|
||||
if ( opt instanceof PositionalOption ) {
|
||||
positionalArguments.push(opt)
|
||||
}
|
||||
})
|
||||
|
||||
const flagArguments: FlagOption<any>[] = []
|
||||
options.forEach(opt => {
|
||||
if ( opt instanceof FlagOption ) {
|
||||
flagArguments.push(opt)
|
||||
}
|
||||
})
|
||||
|
||||
const optionValue: any = {}
|
||||
|
||||
flagArguments.push(this.getHelpOption())
|
||||
|
||||
let expectingFlagArgument = false
|
||||
let positionalFlagName = ''
|
||||
for ( const value of args ) {
|
||||
if ( value.startsWith('--') ) {
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, {
|
||||
expecting: positionalFlagName,
|
||||
})
|
||||
} else {
|
||||
const flagArgument = flagArguments.filter(x => x.longFlag === value)
|
||||
if ( flagArgument.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
if ( flagArgument[0].argumentDescription ) {
|
||||
positionalFlagName = flagArgument[0].getArgumentName()
|
||||
expectingFlagArgument = true
|
||||
} else {
|
||||
optionValue[flagArgument[0].getArgumentName()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( value.startsWith('-') ) {
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
expecting: positionalFlagName,
|
||||
})
|
||||
} else {
|
||||
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
|
||||
if ( flagArgument.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
if ( flagArgument[0].argumentDescription ) {
|
||||
positionalFlagName = flagArgument[0].getArgumentName()
|
||||
expectingFlagArgument = true
|
||||
} else {
|
||||
optionValue[flagArgument[0].getArgumentName()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( expectingFlagArgument ) {
|
||||
const inferredValue = infer(value)
|
||||
const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0]
|
||||
if ( !optionInstance.validate(inferredValue) ) {
|
||||
throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, {
|
||||
requirements: optionInstance.getRequirementDisplays(),
|
||||
})
|
||||
}
|
||||
|
||||
optionValue[positionalFlagName] = inferredValue
|
||||
expectingFlagArgument = false
|
||||
} else {
|
||||
if ( positionalArguments.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
const inferredValue = infer(value)
|
||||
if ( !positionalArguments[0].validate(inferredValue) ) {
|
||||
throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||
requirements: positionalArguments[0].getRequirementDisplays(),
|
||||
})
|
||||
}
|
||||
|
||||
optionValue[positionalArguments[0].getArgumentName()] = infer(value)
|
||||
positionalArguments = positionalArguments.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
|
||||
expecting: positionalFlagName,
|
||||
})
|
||||
}
|
||||
|
||||
if ( positionalArguments.length > 0 ) {
|
||||
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||
expecting: positionalArguments[0].getArgumentName(),
|
||||
})
|
||||
}
|
||||
|
||||
return optionValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of CLIOption based on a string definition of a particular format.
|
||||
*
|
||||
* e.g. '{file name} | canonical name of the resource to create'
|
||||
* e.g. '--push -p {value} | the value to be pushed'
|
||||
* e.g. '--force -f | do a force push'
|
||||
*
|
||||
* @param string
|
||||
*/
|
||||
protected instantiateOptionFromString(string: string): CLIOption<any> {
|
||||
if ( string.startsWith('{') ) {
|
||||
// The string is a positional argument
|
||||
const stringParts = string.split('|').map(x => x.trim())
|
||||
const name = stringParts[0].replace(/\{|\}/g, '')
|
||||
return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name))
|
||||
} else {
|
||||
// The string is a flag argument
|
||||
const stringParts = string.split('|').map(x => x.trim())
|
||||
|
||||
// Parse the flag parts first
|
||||
const hasArgument = stringParts[0].indexOf('{') >= 0
|
||||
const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim()
|
||||
const flagParts = flagString.split(' ')
|
||||
|
||||
let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined
|
||||
if ( !longFlag && flagParts.length > 1 ) {
|
||||
if ( flagParts[1].startsWith('--') ) {
|
||||
longFlag = flagParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined
|
||||
if ( !shortFlag && flagParts.length > 1 ) {
|
||||
if ( flagParts[1].length === 2 ) {
|
||||
shortFlag = flagParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined
|
||||
const description = stringParts.length > 1 ? stringParts[1] : undefined
|
||||
|
||||
return new FlagOption(longFlag, shortFlag, description, argumentDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if, at any point in the arguments, the help option's short or long flag appears.
|
||||
* @returns {boolean} - true if the help flag appeared
|
||||
*/
|
||||
didRequestUsage(argv: string[]): boolean {
|
||||
const helpOption = this.getHelpOption()
|
||||
for ( const arg of argv ) {
|
||||
if ( arg.trim() === helpOption.longFlag || arg.trim() === helpOption.shortFlag ) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
protected nativeOutput(...outputs: any[]): void {
|
||||
console.log(...outputs) // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
64
src/cli/Template.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {UniversalPath} from '../util'
|
||||
|
||||
/**
|
||||
* Interface defining a template that can be generated using the TemplateDirective.
|
||||
*/
|
||||
export interface Template {
|
||||
/**
|
||||
* The name of the template as it will be specified from the command line.
|
||||
*
|
||||
* @example
|
||||
* If this is `'mytemplate'`, then the template will be created with:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex new mytemplate some:path
|
||||
* ```
|
||||
*/
|
||||
name: string,
|
||||
|
||||
/**
|
||||
* The suffix of the file generated by this template.
|
||||
* @example `.mytemplate.ts`
|
||||
* @example `.controller.ts`
|
||||
*/
|
||||
fileSuffix: string,
|
||||
|
||||
/**
|
||||
* Brief description of the template displayed on the --help page for the TemplateDirective.
|
||||
* Should be brief (1 sentence).
|
||||
*/
|
||||
description: string,
|
||||
|
||||
/**
|
||||
* Array of path-strings that are resolved relative to the base `app` directory.
|
||||
* @example `['http', 'controllers']`
|
||||
* @example `['units']`
|
||||
*/
|
||||
baseAppPath: string[],
|
||||
|
||||
/**
|
||||
* Render the given template to a string which will be written to the file.
|
||||
* Note: this method should NOT write the contents to `targetFilePath`.
|
||||
*
|
||||
* @example
|
||||
* If the user enters:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex new mytemplate path:to:NewInstance
|
||||
* ```
|
||||
*
|
||||
* Then, the following params are:
|
||||
* ```typescript
|
||||
* {
|
||||
* name: 'NewInstance',
|
||||
* fullCanonicalPath: 'path:to:NewInstance',
|
||||
* targetFilePath: UniversalPath { }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param name - the singular name of the resource
|
||||
* @param fullCanonicalName - the full canonical name of the resource
|
||||
* @param targetFilePath - the UniversalPath where the file will be written
|
||||
*/
|
||||
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
|
||||
}
|
||||
23
src/cli/decorators.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||
import {CommandLine} from './service'
|
||||
import {Directive} from './Directive'
|
||||
import {logIfDebugging} from '../util'
|
||||
|
||||
/**
|
||||
* Register a class as a command-line Directive.
|
||||
* The class must extend Directive.
|
||||
* @constructor
|
||||
*/
|
||||
export const CLIDirective = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiableOf(target, Directive) ) {
|
||||
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.onResolve<CommandLine>(CommandLine, cli => {
|
||||
cli.registerDirective(target as Instantiable<Directive>)
|
||||
})
|
||||
} else {
|
||||
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/cli/directive/RouteDirective.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {Directive, OptionDefinition} from '../Directive'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import Table = require('cli-table')
|
||||
import {RouteHandler} from '../../http/routing/Route'
|
||||
|
||||
@Injectable()
|
||||
export class RouteDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
getDescription(): string {
|
||||
return 'Get information about a specific route'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['route']
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return [
|
||||
'{route} | the path of the route',
|
||||
'--method -m {value} | the HTTP method of the route',
|
||||
]
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const method: string | undefined = this.option('method')
|
||||
?.toLowerCase()
|
||||
?.trim()
|
||||
|
||||
const route: string = this.option('route')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
|
||||
this.routing.getCompiled()
|
||||
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
|
||||
.tap(matches => {
|
||||
if ( !matches.length ) {
|
||||
this.error('No matching routes found. (Use `./ex routes` to list)')
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
.each(match => {
|
||||
const pre = match.getMiddlewares()
|
||||
.where('stage', '=', 'pre')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
|
||||
const post = match.getMiddlewares()
|
||||
.where('stage', '=', 'post')
|
||||
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
|
||||
|
||||
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
|
||||
|
||||
const table = new Table({
|
||||
head: ['Stage', 'Handler'],
|
||||
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
|
||||
})
|
||||
|
||||
table.push(...pre.toArray())
|
||||
table.push(['handler', match.getDisplayableHandler()])
|
||||
table.push(...post.toArray())
|
||||
|
||||
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||
})
|
||||
}
|
||||
|
||||
protected handlerToString(handler: RouteHandler): string {
|
||||
return typeof handler === 'string' ? handler : '(anonymous function)'
|
||||
}
|
||||
}
|
||||
33
src/cli/directive/RoutesDirective.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Directive} from '../Directive'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import Table = require('cli-table')
|
||||
|
||||
@Injectable()
|
||||
export class RoutesDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
getDescription(): string {
|
||||
return 'List routes registered in the application'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['routes']
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length)
|
||||
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
|
||||
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()])
|
||||
|
||||
const table = new Table({
|
||||
head: ['Route', 'Handler'],
|
||||
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
|
||||
})
|
||||
|
||||
table.push(...rows.toArray())
|
||||
|
||||
this.info('\n' + table)
|
||||
}
|
||||
}
|
||||
31
src/cli/directive/RunDirective.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {Directive} from '../Directive'
|
||||
import {CommandLineApplication} from '../service'
|
||||
import {Injectable} from '../../di'
|
||||
import {ErrorWithContext} from '../../util'
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
|
||||
/**
|
||||
* A directive that starts the framework's final target normally.
|
||||
* In most cases, this runs the HTTP server, which would have been replaced
|
||||
* by the CommandLineApplication unit.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RunDirective extends Directive {
|
||||
getDescription(): string {
|
||||
return 'run the application normally'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['run', 'up']
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
if ( !CommandLineApplication.getReplacement() ) {
|
||||
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
|
||||
}
|
||||
|
||||
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
|
||||
await this.app().startUnit(unit)
|
||||
await this.app().stopUnit(unit)
|
||||
}
|
||||
}
|
||||
48
src/cli/directive/ShellDirective.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {Directive} from '../Directive'
|
||||
import * as colors from 'colors/safe'
|
||||
import * as repl from 'repl'
|
||||
import {DependencyKey} from '../../di'
|
||||
|
||||
/**
|
||||
* Launch an interactive REPL shell from within the application.
|
||||
* This is very useful for debugging and testing things during development.
|
||||
*/
|
||||
export class ShellDirective extends Directive {
|
||||
protected options: any = {
|
||||
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
|
||||
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
||||
}
|
||||
|
||||
/**
|
||||
* The created Node.js REPL server.
|
||||
* @protected
|
||||
*/
|
||||
protected repl?: repl.REPLServer
|
||||
|
||||
getDescription(): string {
|
||||
return 'launch an interactive shell inside your application'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['shell']
|
||||
}
|
||||
|
||||
getHelpText(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const state: any = {
|
||||
app: this.app(),
|
||||
lib: await import('../../index'),
|
||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
||||
}
|
||||
|
||||
await new Promise<void>(res => {
|
||||
this.nativeOutput(this.options.welcome)
|
||||
this.repl = repl.start(this.options.prompt)
|
||||
Object.assign(this.repl.context, state)
|
||||
this.repl.on('exit', () => res())
|
||||
})
|
||||
}
|
||||
}
|
||||
90
src/cli/directive/TemplateDirective.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {Directive, OptionDefinition} from '../Directive'
|
||||
import {PositionalOption} from './options/PositionalOption'
|
||||
import {CommandLine} from '../service'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ErrorWithContext} from '../../util'
|
||||
|
||||
/**
|
||||
* Create a new file based on a template registered with the CommandLine service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TemplateDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['new', 'make']
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'create a new file from a registered template'
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
const registeredTemplates = this.cli.getTemplates()
|
||||
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
|
||||
template.whitelist(...registeredTemplates.pluck('name').all())
|
||||
|
||||
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
|
||||
|
||||
return [template, destination]
|
||||
}
|
||||
|
||||
getHelpText(): string {
|
||||
const registeredTemplates = this.cli.getTemplates()
|
||||
|
||||
return [
|
||||
'Modules in Extollo register templates that can be used to quickly create common file types.',
|
||||
'',
|
||||
'For example, you can create a new model from @extollo/orm using the "model" template:',
|
||||
'',
|
||||
'./ex new model auth:Group',
|
||||
'',
|
||||
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
|
||||
'',
|
||||
'AVAILABLE TEMPLATES:',
|
||||
'',
|
||||
...(registeredTemplates.map(template => {
|
||||
return ` - ${template.name}: ${template.description}`
|
||||
}).all()),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const templateName: string = this.option('template_name')
|
||||
const destinationName: string = this.option('file_name')
|
||||
|
||||
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
|
||||
this.error(`The destination should be a canonical name, not a file path.`)
|
||||
this.error(`Reference sub-directories using the : character instead.`)
|
||||
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const template = this.cli.getTemplate(templateName)
|
||||
if ( !template ) {
|
||||
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
|
||||
templateName,
|
||||
destinationName,
|
||||
})
|
||||
}
|
||||
|
||||
const name = destinationName.split(':').reverse()[0]
|
||||
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
|
||||
|
||||
if ( await path.exists() ) {
|
||||
this.error(`File already exists: ${path}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the parent direction exists
|
||||
await path.concat('..').mkdir()
|
||||
|
||||
const contents = await template.render(name, destinationName, path.clone())
|
||||
await path.write(contents)
|
||||
|
||||
this.success(`Created new ${template.name} in ${path}`)
|
||||
}
|
||||
}
|
||||
54
src/cli/directive/UsageDirective.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Directive} from '../Directive'
|
||||
import {Injectable, Inject} from '../../di'
|
||||
import {padRight} from '../../util'
|
||||
import {CommandLine} from '../service'
|
||||
|
||||
/**
|
||||
* Directive that prints the help message and usage information about
|
||||
* directives registered with the command line utility.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsageDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
public getKeywords(): string | string[] {
|
||||
return 'help'
|
||||
}
|
||||
|
||||
public getDescription(): string {
|
||||
return 'print information about available commands'
|
||||
}
|
||||
|
||||
public handle(): void | Promise<void> {
|
||||
const directiveStrings = this.cli.getDirectives()
|
||||
.map(cls => this.make<Directive>(cls))
|
||||
.map<[string, string]>(dir => {
|
||||
return [dir.getMainKeyword(), dir.getDescription()]
|
||||
})
|
||||
|
||||
const maxLen = directiveStrings.max<number>(x => x[0].length)
|
||||
|
||||
const printStrings = directiveStrings.map(grp => {
|
||||
return [padRight(grp[0], maxLen + 1), grp[1]]
|
||||
})
|
||||
.map(grp => {
|
||||
return ` ${grp[0]}: ${grp[1]}`
|
||||
})
|
||||
.toArray()
|
||||
|
||||
this.nativeOutput(this.cli.getASCIILogo())
|
||||
this.nativeOutput([
|
||||
'',
|
||||
'Welcome to Extollo! Specify a command to get started.',
|
||||
'',
|
||||
`USAGE: ex <directive> [..options]`,
|
||||
'',
|
||||
...printStrings,
|
||||
'',
|
||||
'For usage information about a particular command, pass the --help flag.',
|
||||
'-------------------------------------------',
|
||||
`powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills`,
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
251
src/cli/directive/options/CLIOption.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* A CLI option. Supports basic comparative, and set-based validation.
|
||||
* @class
|
||||
*/
|
||||
export abstract class CLIOption<T> {
|
||||
|
||||
/**
|
||||
* Do we use the whitelist?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected useWhitelist = false
|
||||
|
||||
/**
|
||||
* Do we use the blacklist?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected useBlacklist = false
|
||||
|
||||
/**
|
||||
* Do we use the less-than comparison?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected useLessThan = false
|
||||
|
||||
/**
|
||||
* Do we use the greater-than comparison?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected useGreaterThan = false
|
||||
|
||||
/**
|
||||
* Do we use the equality operator?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected useEquality = false
|
||||
|
||||
/**
|
||||
* Is this option optional?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected isOptional = false
|
||||
|
||||
/**
|
||||
* Whitelisted values.
|
||||
* @type {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
protected whitelistItems: T[] = []
|
||||
|
||||
/**
|
||||
* Blacklisted values.
|
||||
* @type {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
protected blacklistItems: T[] = []
|
||||
|
||||
/**
|
||||
* Value to be compared in less than.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected lessThanValue?: T
|
||||
|
||||
/**
|
||||
* If true, the less than will be less than or equal to.
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected lessThanBit = false
|
||||
|
||||
/**
|
||||
* Value to be compared in greater than.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected greaterThanValue?: T
|
||||
|
||||
/**
|
||||
* If true, the greater than will be greater than or equal to.
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected greaterThanBit = false
|
||||
|
||||
/**
|
||||
* The value to be used to check equality.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected equalityValue?: T
|
||||
|
||||
/**
|
||||
* Whitelist the specified item or items and enable the whitelist.
|
||||
* @param {...*} items - the items to whitelist
|
||||
*/
|
||||
whitelist(...items: T[]): this {
|
||||
this.useWhitelist = true
|
||||
items.forEach(item => this.whitelistItems.push(item))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Blacklist the specified item or items and enable the blacklist.
|
||||
* @param {...*} items - the items to blacklist
|
||||
*/
|
||||
blacklist(...items: T[]): this {
|
||||
this.useBlacklist = true
|
||||
items.forEach(item => this.blacklistItems.push(item))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in less-than comparison and enables less-than comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
lessThan(value: T): this {
|
||||
this.useLessThan = true
|
||||
this.lessThanValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in less-than or equal-to comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
lessThanOrEqualTo(value: T): this {
|
||||
this.lessThanBit = true
|
||||
this.lessThan(value)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in greater-than comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
greaterThan(value: T): this {
|
||||
this.useGreaterThan = true
|
||||
this.greaterThanValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in greater-than or equal-to comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
greaterThanOrEqualTo(value: T): this {
|
||||
this.greaterThanBit = true
|
||||
this.greaterThan(value)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in equality comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
equals(value: T): this {
|
||||
this.useEquality = true
|
||||
this.equalityValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the specified value passes the configured comparisons.
|
||||
* @param value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate(value: T): boolean {
|
||||
let isValid = true
|
||||
if ( this.useEquality ) {
|
||||
isValid = isValid && (this.equalityValue === value)
|
||||
}
|
||||
|
||||
if ( this.useLessThan && typeof this.lessThanValue !== 'undefined' ) {
|
||||
if ( this.lessThanBit ) {
|
||||
isValid = isValid && (value <= this.lessThanValue)
|
||||
} else {
|
||||
isValid = isValid && (value < this.lessThanValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.useGreaterThan && typeof this.greaterThanValue !== 'undefined' ) {
|
||||
if ( this.greaterThanBit ) {
|
||||
isValid = isValid && (value >= this.greaterThanValue)
|
||||
} else {
|
||||
isValid = isValid && (value > this.greaterThanValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.useWhitelist ) {
|
||||
isValid = isValid && this.whitelistItems.some(x => {
|
||||
return x === value
|
||||
})
|
||||
}
|
||||
|
||||
if ( this.useBlacklist ) {
|
||||
isValid = isValid && !(this.blacklistItems.some(x => x === value))
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Option as optional.
|
||||
*/
|
||||
optional(): this {
|
||||
this.isOptional = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the argument name. Should be overridden by child classes.
|
||||
* @returns {string}
|
||||
*/
|
||||
abstract getArgumentName(): string
|
||||
|
||||
/**
|
||||
* Get an array of strings denoting the human-readable requirements for this option to be valid.
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getRequirementDisplays(): string[] {
|
||||
const clauses = []
|
||||
|
||||
if ( this.useBlacklist ) {
|
||||
clauses.push(`must not be one of: ${this.blacklistItems.map(x => String(x)).join(', ')}`)
|
||||
}
|
||||
|
||||
if ( this.useWhitelist ) {
|
||||
clauses.push(`must be one of: ${this.whitelistItems.map(x => String(x)).join(', ')}`)
|
||||
}
|
||||
|
||||
if ( this.useGreaterThan ) {
|
||||
clauses.push(`must be greater than${this.greaterThanBit ? ' or equal to' : ''}: ${String(this.greaterThanValue)}`)
|
||||
}
|
||||
|
||||
if ( this.useLessThan ) {
|
||||
clauses.push(`must be less than${this.lessThanBit ? ' or equal to' : ''}: ${String(this.lessThanValue)}`)
|
||||
}
|
||||
|
||||
if ( this.useEquality ) {
|
||||
clauses.push(`must be equal to: ${String(this.equalityValue)}`)
|
||||
}
|
||||
|
||||
return clauses
|
||||
}
|
||||
}
|
||||
47
src/cli/directive/options/FlagOption.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {CLIOption} from './CLIOption'
|
||||
|
||||
/**
|
||||
* Non-positional, flag-based CLI option.
|
||||
*/
|
||||
export class FlagOption<T> extends CLIOption<T> {
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The long-form flag for this option.
|
||||
* @example --path, --create
|
||||
*/
|
||||
public readonly longFlag?: string,
|
||||
/**
|
||||
* The short-form flag for this option.
|
||||
* @example -p, -c
|
||||
*/
|
||||
public readonly shortFlag?: string,
|
||||
/**
|
||||
* Usage message describing this flag.
|
||||
*/
|
||||
public readonly message?: string,
|
||||
/**
|
||||
* Description of the argument required by this flag.
|
||||
* If this is set, the flag will expect a positional argument to follow as a param.
|
||||
*/
|
||||
public readonly argumentDescription?: string,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the referential name for this option.
|
||||
* Defaults to the long flag (without the '--'). If this cannot
|
||||
* be found, the short flag (without the '-') is used.
|
||||
* @returns {string}
|
||||
*/
|
||||
getArgumentName(): string {
|
||||
if ( this.longFlag ) {
|
||||
return this.longFlag.replace('--', '')
|
||||
} else if ( this.shortFlag ) {
|
||||
return this.shortFlag.replace('-', '')
|
||||
}
|
||||
|
||||
throw new Error('Missing either a long- or short-flag for FlagOption.')
|
||||
}
|
||||
}
|
||||
34
src/cli/directive/options/PositionalOption.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {CLIOption} from './CLIOption'
|
||||
|
||||
/**
|
||||
* A positional CLI option. Defined without a flag.
|
||||
*/
|
||||
export class PositionalOption<T> extends CLIOption<T> {
|
||||
|
||||
/**
|
||||
* Instantiate the option.
|
||||
* @param {string} name - the name of the option
|
||||
* @param {string} message - message describing the option
|
||||
*/
|
||||
constructor(
|
||||
/**
|
||||
* The display name of this positional argument.
|
||||
* @example path, filename
|
||||
*/
|
||||
public readonly name: string,
|
||||
/**
|
||||
* A usage message describing this parameter.
|
||||
*/
|
||||
public readonly message: string = '',
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the name of the option.
|
||||
* @returns {string}
|
||||
*/
|
||||
getArgumentName(): string {
|
||||
return this.name
|
||||
}
|
||||
}
|
||||
15
src/cli/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from './Directive'
|
||||
export * from './Template'
|
||||
|
||||
export * from './service/CommandLineApplication'
|
||||
export * from './service/CommandLine'
|
||||
|
||||
export * from './directive/options/CLIOption'
|
||||
export * from './directive/options/FlagOption'
|
||||
export * from './directive/options/PositionalOption'
|
||||
|
||||
export * from './directive/ShellDirective'
|
||||
export * from './directive/TemplateDirective'
|
||||
export * from './directive/UsageDirective'
|
||||
|
||||
export * from './decorators'
|
||||
130
src/cli/service/CommandLine.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {Singleton, Instantiable, Inject} from '../../di'
|
||||
import {Collection} from '../../util'
|
||||
import {CommandLineApplication} from './CommandLineApplication'
|
||||
import {Directive} from '../Directive'
|
||||
import {Template} from '../Template'
|
||||
import {templateDirective} from '../templates/directive'
|
||||
import {templateUnit} from '../templates/unit'
|
||||
import {templateController} from '../templates/controller'
|
||||
import {templateMiddleware} from '../templates/middleware'
|
||||
import {templateRoutes} from '../templates/routes'
|
||||
import {templateConfig} from '../templates/config'
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
import {Logging} from '../../service/Logging'
|
||||
|
||||
/**
|
||||
* Service for managing directives, templates, and other resources related
|
||||
* to the command line utilities.
|
||||
*/
|
||||
@Singleton()
|
||||
export class CommandLine extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** Directive classes registered with the CLI command. */
|
||||
protected directives: Collection<Instantiable<Directive>> = new Collection<Instantiable<Directive>>()
|
||||
|
||||
/** Templates registered with the CLI command. These can be created with the TemplateDirective. */
|
||||
protected templates: Collection<Template> = new Collection<Template>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.registerTemplate(templateDirective)
|
||||
this.registerTemplate(templateUnit)
|
||||
this.registerTemplate(templateController)
|
||||
this.registerTemplate(templateMiddleware)
|
||||
this.registerTemplate(templateRoutes)
|
||||
this.registerTemplate(templateConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the application was started from the command line.
|
||||
*/
|
||||
public isCLI(): boolean {
|
||||
return this.app().hasUnit(CommandLineApplication)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the Extollo ASCII logo.
|
||||
*/
|
||||
public getASCIILogo(): string {
|
||||
return ` _
|
||||
/ /\\ ______ _ _ _
|
||||
/ / \\ | ____| | | | | |
|
||||
/ / /\\ \\ | |__ __ _| |_ ___ | | | ___
|
||||
/ / /\\ \\ \\ | __| \\ \\/ / __/ _ \\| | |/ _ \\
|
||||
/ / / \\ \\_\\ | |____ > <| || (_) | | | (_) |
|
||||
\\/_/ \\/_/ |______/_/\\_\\\\__\\___/|_|_|\\___/
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Directive class with this service. This will make
|
||||
* the directive available for use on the CLI.
|
||||
* @param directiveClass
|
||||
*/
|
||||
public registerDirective(directiveClass: Instantiable<Directive>): this {
|
||||
if ( !this.directives.includes(directiveClass) ) {
|
||||
this.directives.push(directiveClass)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given directive is registered with this service.
|
||||
* @param directiveClass
|
||||
*/
|
||||
public hasDirective(directiveClass: Instantiable<Directive>): boolean {
|
||||
return this.directives.includes(directiveClass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of all registered directives.
|
||||
*/
|
||||
public getDirectives(): Collection<Instantiable<Directive>> {
|
||||
return this.directives.clone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given template with this service. This makes the template
|
||||
* available for use with the TemplateDirective service.
|
||||
* @param template
|
||||
*/
|
||||
public registerTemplate(template: Template): this {
|
||||
if ( !this.templates.firstWhere('name', '=', template.name) ) {
|
||||
this.templates.push(template)
|
||||
} else {
|
||||
this.logging.warn(`Duplicate template will not be registered: ${template.name}`)
|
||||
this.logging.debug(`Duplicate template registered at: ${(new Error()).stack}`)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a template with the given name exists.
|
||||
* @param name
|
||||
*/
|
||||
public hasTemplate(name: string): boolean {
|
||||
return Boolean(this.templates.firstWhere('name', '=', name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the template with the given name, if one exists.
|
||||
* @param name
|
||||
*/
|
||||
public getTemplate(name: string): Template | undefined {
|
||||
return this.templates.firstWhere('name', '=', name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of all registered templates.
|
||||
*/
|
||||
public getTemplates(): Collection<Template> {
|
||||
return this.templates.clone()
|
||||
}
|
||||
}
|
||||
62
src/cli/service/CommandLineApplication.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {Unit} from '../../lifecycle/Unit'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {Singleton, Inject} from '../../di/decorator/injection'
|
||||
import {CommandLine} from './CommandLine'
|
||||
import {UsageDirective} from '../directive/UsageDirective'
|
||||
import {Directive} from '../Directive'
|
||||
import {ShellDirective} from '../directive/ShellDirective'
|
||||
import {TemplateDirective} from '../directive/TemplateDirective'
|
||||
import {RunDirective} from '../directive/RunDirective'
|
||||
import {RoutesDirective} from '../directive/RoutesDirective'
|
||||
import {RouteDirective} from '../directive/RouteDirective'
|
||||
|
||||
/**
|
||||
* Unit that takes the place of the final unit in the application that handles
|
||||
* invocations from the command line.
|
||||
*/
|
||||
@Singleton()
|
||||
export class CommandLineApplication extends Unit {
|
||||
/** The unit that was replaced by the CLI app. */
|
||||
private static replacement?: typeof Unit
|
||||
|
||||
/** Set the replaced unit. */
|
||||
public static setReplacement(unitClass?: typeof Unit): void {
|
||||
this.replacement = unitClass
|
||||
}
|
||||
|
||||
/** Get the replaced unit. */
|
||||
public static getReplacement(): typeof Unit | undefined {
|
||||
return this.replacement
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public async up(): Promise<void> {
|
||||
this.cli.registerDirective(UsageDirective)
|
||||
this.cli.registerDirective(ShellDirective)
|
||||
this.cli.registerDirective(TemplateDirective)
|
||||
this.cli.registerDirective(RunDirective)
|
||||
this.cli.registerDirective(RoutesDirective)
|
||||
this.cli.registerDirective(RouteDirective)
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const match = this.cli.getDirectives()
|
||||
.map(dirCls => this.make<Directive>(dirCls))
|
||||
.firstWhere(dir => dir.matchesKeyword(argv[0]))
|
||||
|
||||
if ( match ) {
|
||||
await match.invoke(argv.slice(1))
|
||||
} else {
|
||||
const usage = this.make<UsageDirective>(UsageDirective)
|
||||
await usage.handle()
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/cli/service/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './CommandLine'
|
||||
export * from './CommandLineApplication'
|
||||
21
src/cli/templates/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* A template that generates a new configuration file in the app/configs directory.
|
||||
*/
|
||||
const templateConfig: Template = {
|
||||
name: 'config',
|
||||
fileSuffix: '.config.ts',
|
||||
description: 'Create a new config file.',
|
||||
baseAppPath: ['configs'],
|
||||
render() {
|
||||
return `import { env } from '@extollo/lib'
|
||||
|
||||
export default {
|
||||
key: env('VALUE_ENV_VAR', 'default value'),
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateConfig }
|
||||
29
src/cli/templates/controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* Template that generates a new controller in the app/http/controllers directory.
|
||||
*/
|
||||
const templateController: Template = {
|
||||
name: 'controller',
|
||||
fileSuffix: '.controller.ts',
|
||||
description: 'Create a controller class that can be used to handle requests.',
|
||||
baseAppPath: ['http', 'controllers'],
|
||||
render(name: string) {
|
||||
return `import {Controller, view, Inject, Injectable} from '@extollo/lib'
|
||||
|
||||
/**
|
||||
* ${name} Controller
|
||||
* ------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Controller {
|
||||
public ${name.toLowerCase()}() {
|
||||
return view('${name.toLowerCase()}')
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateController }
|
||||
41
src/cli/templates/directive.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* Template that generates a new Directive class in the app/directives directory.
|
||||
*/
|
||||
const templateDirective: Template = {
|
||||
name: 'directive',
|
||||
fileSuffix: '.directive.ts',
|
||||
description: 'Create a new Directive class which adds functionality to the ./ex command.',
|
||||
baseAppPath: ['directives'],
|
||||
render(name: string) {
|
||||
return `import {Directive, OptionDefinition, Injectable} from '@extollo/lib'
|
||||
|
||||
/**
|
||||
* ${name} Directive
|
||||
* ---------------------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name}Directive extends Directive {
|
||||
getKeywords(): string | string[] {
|
||||
return ['${name.toLowerCase()}']
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
async handle(argv: string[]) {
|
||||
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateDirective }
|
||||
29
src/cli/templates/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* Template that generates a new middleware class in app/http/middlewares.
|
||||
*/
|
||||
const templateMiddleware: Template = {
|
||||
name: 'middleware',
|
||||
fileSuffix: '.middleware.ts',
|
||||
description: 'Create a middleware class that can be applied to routes.',
|
||||
baseAppPath: ['http', 'middlewares'],
|
||||
render(name: string) {
|
||||
return `import {Middleware, Injectable} from '@extollo/lib'
|
||||
|
||||
/**
|
||||
* ${name} Middleware
|
||||
* --------------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Middleware {
|
||||
public async apply() {
|
||||
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateMiddleware }
|
||||
25
src/cli/templates/routes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* Template that generates a new route definition file in app/http/routes.
|
||||
*/
|
||||
const templateRoutes: Template = {
|
||||
name: 'routes',
|
||||
fileSuffix: '.routes.ts',
|
||||
description: 'Create a file for route definitions.',
|
||||
baseAppPath: ['http', 'routes'],
|
||||
render(name: string) {
|
||||
return `import {Route} from '@extollo/lib'
|
||||
|
||||
/*
|
||||
* ${name} Routes
|
||||
* -------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
|
||||
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateRoutes }
|
||||
36
src/cli/templates/unit.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {Template} from '../Template'
|
||||
|
||||
/**
|
||||
* Template that generates a new application unit class in app/units.
|
||||
*/
|
||||
const templateUnit: Template = {
|
||||
name: 'unit',
|
||||
fileSuffix: '.ts',
|
||||
description: 'Create a service unit that will start and stop with your application.',
|
||||
baseAppPath: ['units'],
|
||||
render(name: string) {
|
||||
return `import {Singleton, Inject, Unit, Logging} from '@extollo/lib'
|
||||
|
||||
/**
|
||||
* ${name} Unit
|
||||
* ---------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Singleton()
|
||||
export class ${name} extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public async up() {
|
||||
this.logging.info('${name} has started!')
|
||||
}
|
||||
|
||||
public async down() {
|
||||
this.logging.info('${name} has stopped!')
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { templateUnit }
|
||||
11
src/cli/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
378
src/di/Container.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import SingletonFactory from './factory/SingletonFactory'
|
||||
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
|
||||
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
|
||||
|
||||
export type MaybeFactory<T> = AbstractFactory<T> | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
|
||||
|
||||
/**
|
||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||
*/
|
||||
export class Container {
|
||||
/**
|
||||
* Given a Container instance, apply the ContainerBlueprint to it.
|
||||
* @param container
|
||||
*/
|
||||
public static realizeContainer<T extends Container>(container: T): T {
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolve()
|
||||
.map(factory => container.registerFactory(factory))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveConstructable()
|
||||
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
|
||||
|
||||
ContainerBlueprint.getContainerBlueprint()
|
||||
.resolveResolutionCallbacks()
|
||||
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
|
||||
container.onResolve(listener.key)
|
||||
.then(value => listener.callback(value))
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global instance of this container.
|
||||
*/
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = Container.realizeContainer(new Container())
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of factories registered with this container.
|
||||
* @type Collection<AbstractFactory>
|
||||
*/
|
||||
protected factories: Collection<AbstractFactory<unknown>> = new Collection<AbstractFactory<unknown>>()
|
||||
|
||||
/**
|
||||
* Collection of singleton instances produced by this container.
|
||||
* @type Collection<InstanceRef>
|
||||
*/
|
||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
||||
|
||||
/**
|
||||
* Collection of callbacks waiting for a dependency key to be resolved.
|
||||
* @protected
|
||||
*/
|
||||
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all factories and instances of the given key from this container.
|
||||
* @param key
|
||||
*/
|
||||
purge(key: DependencyKey): this {
|
||||
this.factories = this.factories.filter(x => !x.match(key))
|
||||
this.release(key)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all stored instances of the given key from this container.
|
||||
* @param key
|
||||
*/
|
||||
release(key: DependencyKey): this {
|
||||
this.instances = this.instances.filter(x => x.key !== key)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>): this {
|
||||
if ( this.resolve(dependency) ) {
|
||||
throw new DuplicateFactoryKeyError(dependency)
|
||||
}
|
||||
|
||||
const factory = new Factory(dependency)
|
||||
this.factories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given function as a factory within the container.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {function} producer - factory to produce a value
|
||||
*/
|
||||
registerProducer(name: DependencyKey, producer: () => any): this {
|
||||
if ( this.resolve(name) ) {
|
||||
throw new DuplicateFactoryKeyError(name)
|
||||
}
|
||||
|
||||
const factory = new ClosureFactory(name, producer)
|
||||
this.factories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||
if ( this.resolve(name) ) {
|
||||
throw new DuplicateFactoryKeyError(name)
|
||||
}
|
||||
|
||||
const factory = new NamedFactory(name, dependency)
|
||||
this.factories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a value as a singleton in the container. It will not be instantiated, but
|
||||
* can be injected by its unique name.
|
||||
* @param {string} key - unique name to identify the singleton in the container
|
||||
* @param value
|
||||
*/
|
||||
registerSingleton<T>(key: DependencyKey, value: T): this {
|
||||
if ( this.resolve(key) ) {
|
||||
throw new DuplicateFactoryKeyError(key)
|
||||
}
|
||||
|
||||
this.factories.push(new SingletonFactory(key, value))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a static class to the container along with its already-instantiated
|
||||
* instance that will be used to resolve the class.
|
||||
* @param staticClass
|
||||
* @param instance
|
||||
*/
|
||||
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||
if ( this.resolve(staticClass) ) {
|
||||
throw new DuplicateFactoryKeyError(staticClass)
|
||||
}
|
||||
|
||||
this.register(staticClass)
|
||||
this.instances.push({
|
||||
key: staticClass,
|
||||
value: instance,
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a given factory with the container.
|
||||
* @param {AbstractFactory} factory
|
||||
*/
|
||||
registerFactory(factory: AbstractFactory<unknown>): this {
|
||||
if ( !this.factories.includes(factory) ) {
|
||||
this.factories.push(factory)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has an already-produced value for the given key.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
hasInstance(key: DependencyKey): boolean {
|
||||
return this.instances.where('key', '=', key).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Promise that resolves the first time the given dependency key is resolved
|
||||
* by the application. If it has already been resolved, the Promise will resolve immediately.
|
||||
* @param key
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
|
||||
if ( this.hasInstance(key) ) {
|
||||
return new Promise<T>(res => res(this.make<T>(key)))
|
||||
}
|
||||
|
||||
// Otherwise, we haven't instantiated an instance with this key yet,
|
||||
// so put it onto the waitlist.
|
||||
return new Promise<T>(res => {
|
||||
this.waitingResolveCallbacks.push({
|
||||
key,
|
||||
callback: (res as (t: unknown) => unknown),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has a factory for the given key.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
hasKey(key: DependencyKey): boolean {
|
||||
return Boolean(this.resolve(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the already-produced value for the given key, if one exists.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||
const instances = this.instances.where('key', '=', key)
|
||||
if ( instances.isNotEmpty() ) {
|
||||
return instances.first()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the factory for the given key, if one is registered with this container.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
resolve(key: DependencyKey): MaybeFactory<unknown> {
|
||||
const factory = this.factories.firstWhere(item => item.match(key))
|
||||
if ( factory ) {
|
||||
return factory
|
||||
} else {
|
||||
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {DependencyKey} key
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
resolveAndCreate(key: DependencyKey, ...parameters: any[]): any {
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate', key, {parameters})
|
||||
|
||||
// If we've already instantiated this, just return that
|
||||
const instance = this.getExistingInstance(key)
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate existing instance?', instance)
|
||||
if ( typeof instance !== 'undefined' ) {
|
||||
return instance.value
|
||||
}
|
||||
|
||||
// Otherwise, attempt to create it
|
||||
const factory = this.resolve(key)
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate factory', factory)
|
||||
if ( !factory ) {
|
||||
throw new InvalidDependencyKeyError(key)
|
||||
}
|
||||
|
||||
// Produce and store a new instance
|
||||
const newInstance = this.produceFactory(factory, parameters)
|
||||
this.instances.push({
|
||||
key,
|
||||
value: newInstance,
|
||||
})
|
||||
|
||||
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
|
||||
if ( waiter.key === key ) {
|
||||
waiter.callback(newInstance)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a factory and manually-provided parameters, resolve the dependencies for the
|
||||
* factory and produce its value.
|
||||
* @param {AbstractFactory} factory
|
||||
* @param {array} parameters
|
||||
*/
|
||||
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
|
||||
// Create the dependencies for the factory
|
||||
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
||||
const dependencies = keys.map<ResolvedDependency>(req => {
|
||||
return {
|
||||
paramIndex: req.paramIndex,
|
||||
key: req.key,
|
||||
resolved: this.resolveAndCreate(req.key),
|
||||
}
|
||||
}).sortBy('paramIndex')
|
||||
|
||||
// Build the arguments for the factory, using dependencies in the
|
||||
// correct paramIndex positions, or parameters of we don't have
|
||||
// the dependency.
|
||||
const constructorArguments = []
|
||||
const params = collect(parameters).reverse()
|
||||
for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) {
|
||||
const dep = dependencies.firstWhere('paramIndex', '=', i)
|
||||
if ( dep ) {
|
||||
constructorArguments.push(dep.resolved)
|
||||
} else {
|
||||
constructorArguments.push(params.pop())
|
||||
}
|
||||
}
|
||||
|
||||
// Produce a new instance
|
||||
const inst = factory.produce(constructorArguments, params.reverse().all())
|
||||
|
||||
factory.getInjectedProperties().each(dependency => {
|
||||
if ( dependency.key && inst ) {
|
||||
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||
}
|
||||
})
|
||||
|
||||
return inst
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of the given target. The target can either be a DependencyKey registered with
|
||||
* this container (in which case, the singleton value will be returned), or an instantiable class.
|
||||
*
|
||||
* If the instantiable class has the Injectable decorator, its injectable parameters will be automatically
|
||||
* injected into the instance.
|
||||
* @param {DependencyKey} target
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
make<T>(target: DependencyKey, ...parameters: any[]): T {
|
||||
if ( this.hasKey(target) ) {
|
||||
return this.resolveAndCreate(target, ...parameters)
|
||||
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
|
||||
return this.produceFactory(new Factory(target), parameters)
|
||||
} else {
|
||||
throw new TypeError(`Invalid or unknown make target: ${target}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of dependency keys required by the given target, if it is registered with this container.
|
||||
* @param {DependencyKey} target
|
||||
*/
|
||||
getDependencies(target: DependencyKey): Collection<DependencyKey> {
|
||||
const factory = this.resolve(target)
|
||||
|
||||
if ( !factory ) {
|
||||
throw new InvalidDependencyKeyError(target)
|
||||
}
|
||||
|
||||
return factory.getDependencyKeys().pluck('key')
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a different container, copy the factories and instances from this container over to it.
|
||||
* @param container
|
||||
*/
|
||||
cloneTo(container: Container): this {
|
||||
container.factories = this.factories.clone()
|
||||
container.instances = this.instances.clone()
|
||||
return this
|
||||
}
|
||||
}
|
||||
107
src/di/ContainerBlueprint.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {DependencyKey, Instantiable, StaticClass, TypedDependencyKey} from './types'
|
||||
import NamedFactory from './factory/NamedFactory'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
import {Factory} from './factory/Factory'
|
||||
import {ClosureFactory} from './factory/ClosureFactory'
|
||||
|
||||
/** Simple type alias for a callback to a container's onResolve method. */
|
||||
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
|
||||
|
||||
/**
|
||||
* Blueprint for newly-created containers.
|
||||
*
|
||||
* This is used to allow global helpers like `@Singleton()`
|
||||
* or `@CLIDirective()` while still supporting multiple
|
||||
* global Container instances at once.
|
||||
*/
|
||||
export class ContainerBlueprint {
|
||||
private static instance?: ContainerBlueprint
|
||||
|
||||
public static getContainerBlueprint(): ContainerBlueprint {
|
||||
if ( !this.instance ) {
|
||||
this.instance = new ContainerBlueprint()
|
||||
}
|
||||
|
||||
return this.instance
|
||||
}
|
||||
|
||||
protected factories: (() => AbstractFactory<any>)[] = []
|
||||
|
||||
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = []
|
||||
|
||||
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = []
|
||||
|
||||
/**
|
||||
* Register some factory class with the container. Should take no construction params.
|
||||
* @param factory
|
||||
*/
|
||||
registerFactory(factory: StaticClass<AbstractFactory<any>, any>): this {
|
||||
this.constructableFactories.push(factory)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||
this.factories.push(() => new NamedFactory(name, dependency))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>): this {
|
||||
this.factories.push(() => new Factory(dependency))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a producer function as a ClosureFactory with this container.
|
||||
* @param key
|
||||
* @param producer
|
||||
*/
|
||||
registerProducer(key: DependencyKey, producer: () => any): this {
|
||||
this.factories.push(() => new ClosureFactory(key, producer))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of factory instances in the blueprint.
|
||||
*/
|
||||
resolve(): AbstractFactory<any>[] {
|
||||
return this.factories.map(x => x())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an onResolve callback to be added to all newly-created containers.
|
||||
* @param key
|
||||
* @param callback
|
||||
*/
|
||||
onResolve<T>(key: TypedDependencyKey<T>, callback: ContainerResolutionCallback<T>): this {
|
||||
this.resolutionCallbacks.push({
|
||||
key,
|
||||
callback,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of static Factory classes that need to be instantiated by
|
||||
* the container itself.
|
||||
*/
|
||||
resolveConstructable(): StaticClass<AbstractFactory<any>, any> {
|
||||
return [...this.constructableFactories]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of DependencyKey-callback pairs to register with new containers.
|
||||
*/
|
||||
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
|
||||
return [...this.resolutionCallbacks]
|
||||
}
|
||||
}
|
||||
141
src/di/ScopedContainer.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {Container, MaybeDependency, MaybeFactory} from './Container'
|
||||
import {DependencyKey, Instantiable, StaticClass} from './types'
|
||||
import {AbstractFactory} from './factory/AbstractFactory'
|
||||
|
||||
/**
|
||||
* A container that uses some parent container as a base, but
|
||||
* can have other factories distinct from that parent.
|
||||
*
|
||||
* If an instance is not found in this container, it will be resolved from
|
||||
* the parent container.
|
||||
*
|
||||
* However, if an instance IS found in this container, it will ALWAYS be
|
||||
* resolved from this container, rather than the parent.
|
||||
*
|
||||
* This can be used to create scope-specific containers that can still resolve
|
||||
* the global dependencies, while keeping scope-specific dependencies separate.
|
||||
*
|
||||
* @example
|
||||
* The Request class from @extollo/lib is a ScopedContainer. It can resolve
|
||||
* all dependencies that exist in the global Container, but it can also have
|
||||
* request-specific services (like the Session) injected into it.
|
||||
*
|
||||
* @extends Container
|
||||
*/
|
||||
export class ScopedContainer extends Container {
|
||||
/**
|
||||
* Create a new scoped container based on a parent container instance.
|
||||
* @param container
|
||||
*/
|
||||
public static fromParent(container: Container): ScopedContainer {
|
||||
return new ScopedContainer(container)
|
||||
}
|
||||
|
||||
private resolveParentScope = true
|
||||
|
||||
constructor(
|
||||
private parentContainer: Container,
|
||||
) {
|
||||
super()
|
||||
this.registerSingletonInstance<ScopedContainer>(ScopedContainer, this)
|
||||
}
|
||||
|
||||
hasInstance(key: DependencyKey): boolean {
|
||||
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
|
||||
}
|
||||
|
||||
hasKey(key: DependencyKey): boolean {
|
||||
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
|
||||
}
|
||||
|
||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||
const inst = super.getExistingInstance(key)
|
||||
if ( inst ) {
|
||||
return inst
|
||||
}
|
||||
|
||||
if ( this.resolveParentScope ) {
|
||||
return this.parentContainer.getExistingInstance(key)
|
||||
}
|
||||
}
|
||||
|
||||
resolve(key: DependencyKey): MaybeFactory<any> {
|
||||
const factory = super.resolve(key)
|
||||
if ( factory ) {
|
||||
return factory
|
||||
}
|
||||
|
||||
if ( this.resolveParentScope ) {
|
||||
return this.parentContainer.resolve(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>): this {
|
||||
return this.withoutParentScopes(() => super.register(dependency))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given function as a factory within the container.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {function} producer - factory to produce a value
|
||||
*/
|
||||
registerProducer(name: DependencyKey, producer: () => any): this {
|
||||
return this.withoutParentScopes(() => super.registerProducer(name, producer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>): this {
|
||||
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a value as a singleton in the container. It will not be instantiated, but
|
||||
* can be injected by its unique name.
|
||||
* @param {string} key - unique name to identify the singleton in the container
|
||||
* @param value
|
||||
*/
|
||||
registerSingleton<T>(key: DependencyKey, value: T): this {
|
||||
return this.withoutParentScopes(() => super.registerSingleton(key, value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a static class to the container along with its already-instantiated
|
||||
* instance that will be used to resolve the class.
|
||||
* @param staticClass
|
||||
* @param instance
|
||||
*/
|
||||
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
|
||||
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a given factory with the container.
|
||||
* @param {AbstractFactory} factory
|
||||
*/
|
||||
registerFactory(factory: AbstractFactory<unknown>): this {
|
||||
return this.withoutParentScopes(() => super.registerFactory(factory))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a closure on this container, disabling parent-resolution.
|
||||
* Effectively, the closure will have access to this container as if
|
||||
* it were NOT a scoped container, and only contained its factories.
|
||||
* @param closure
|
||||
*/
|
||||
withoutParentScopes<T>(closure: () => T): T {
|
||||
const oldResolveParentScope = this.resolveParentScope
|
||||
this.resolveParentScope = false
|
||||
const value: T = closure()
|
||||
this.resolveParentScope = oldResolveParentScope
|
||||
return value
|
||||
}
|
||||
}
|
||||
174
src/di/decorator/injection.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'reflect-metadata'
|
||||
import {collect, Collection, logIfDebugging} from '../../util'
|
||||
import {
|
||||
DependencyKey,
|
||||
DependencyRequirement,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
isInstantiable,
|
||||
InjectionType,
|
||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||
PropertyDependency,
|
||||
} from '../types'
|
||||
import {ContainerBlueprint} from '../ContainerBlueprint'
|
||||
|
||||
/**
|
||||
* Get a collection of dependency requirements for the given target object.
|
||||
* @param {Object} target
|
||||
* @return Collection<DependencyRequirement>
|
||||
*/
|
||||
function initDependencyMetadata(target: unknown): Collection<DependencyRequirement> {
|
||||
const paramTypes = Reflect.getMetadata('design:paramtypes', target as any)
|
||||
return collect<DependencyKey>(paramTypes).map<DependencyRequirement>((type, idx) => {
|
||||
return {
|
||||
paramIndex: idx,
|
||||
key: type,
|
||||
overridden: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Class decorator that marks a class as injectable. When this is applied, dependency
|
||||
* metadata for the constructors params is resolved and stored in metadata.
|
||||
* @constructor
|
||||
*/
|
||||
export const Injectable = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
const meta = initDependencyMetadata(target)
|
||||
const existing = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
|
||||
const newMetadata = new Collection<DependencyRequirement>()
|
||||
|
||||
if ( existing ) {
|
||||
const maxNew = meta.max('paramIndex')
|
||||
const maxExisting = existing.max('paramIndex')
|
||||
for ( let i = 0; i <= Math.max(maxNew, maxExisting); i++ ) {
|
||||
const existingDR = existing.firstWhere('paramIndex', '=', i)
|
||||
const newDR = meta.firstWhere('paramIndex', '=', i)
|
||||
|
||||
if ( existingDR && !newDR ) {
|
||||
newMetadata.push(existingDR)
|
||||
} else if ( newDR && !existingDR ) {
|
||||
newMetadata.push(newDR)
|
||||
} else if ( newDR && existingDR ) {
|
||||
if ( existingDR.overridden ) {
|
||||
newMetadata.push(existingDR)
|
||||
} else {
|
||||
newMetadata.push(newDR)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newMetadata.concat(meta)
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, newMetadata, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given class property to be injected by the container.
|
||||
* If a `key` is specified, that DependencyKey will be injected.
|
||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||
* @param key
|
||||
* @param debug
|
||||
* @constructor
|
||||
*/
|
||||
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||
if ( !propertyMetadata ) {
|
||||
propertyMetadata = new Collection<PropertyDependency>()
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
|
||||
const type = Reflect.getMetadata('design:type', target, property)
|
||||
if ( !key && type ) {
|
||||
key = type
|
||||
}
|
||||
|
||||
if ( key ) {
|
||||
const existing = propertyMetadata.firstWhere('property', '=', property)
|
||||
if ( existing ) {
|
||||
existing.key = key
|
||||
} else {
|
||||
propertyMetadata.push({
|
||||
property,
|
||||
key,
|
||||
debug,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ( debug ) {
|
||||
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter decorator to manually mark a parameter as being an injection target on injectable
|
||||
* classes. This can be used to override the dependency key of a given parameter.
|
||||
* @param {DependencyKey} key
|
||||
* @constructor
|
||||
*/
|
||||
export const InjectParam = (key: DependencyKey): ParameterDecorator => {
|
||||
return (target, property, paramIndex) => {
|
||||
if ( !Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target) ) {
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, initDependencyMetadata(target), target)
|
||||
}
|
||||
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
|
||||
const req = meta.firstWhere('paramIndex', '=', paramIndex)
|
||||
if ( req ) {
|
||||
req.key = key
|
||||
req.overridden = true
|
||||
} else {
|
||||
meta.push({
|
||||
paramIndex,
|
||||
key,
|
||||
overridden: true,
|
||||
})
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, meta, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class decorator that registers the class as a singleton instance in the global container.
|
||||
* @param {string} name
|
||||
*/
|
||||
export const Singleton = (name?: string): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiable(target) ) {
|
||||
const injectionType: InjectionType = {
|
||||
type: name ? 'named' : 'singleton',
|
||||
...(name ? { name } : {}),
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
|
||||
Injectable()(target)
|
||||
|
||||
if ( name ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
|
||||
} else {
|
||||
ContainerBlueprint.getContainerBlueprint().register(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a factory class directly with any created containers.
|
||||
* @constructor
|
||||
*/
|
||||
export const FactoryProducer = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiable(target) ) {
|
||||
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/di/error/DuplicateFactoryKeyError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {DependencyKey} from '../types'
|
||||
|
||||
/**
|
||||
* Error thrown when a factory is registered with a duplicate dependency key.
|
||||
* @extends Error
|
||||
*/
|
||||
export class DuplicateFactoryKeyError extends Error {
|
||||
constructor(key: DependencyKey) {
|
||||
super(`A factory definition already exists with the key for ${key}.`)
|
||||
}
|
||||
}
|
||||
11
src/di/error/InvalidDependencyKeyError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {DependencyKey} from '../types'
|
||||
|
||||
/**
|
||||
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidDependencyKeyError extends Error {
|
||||
constructor(key: DependencyKey) {
|
||||
super(`No such dependency is registered with this container: ${key}`)
|
||||
}
|
||||
}
|
||||
44
src/di/factory/AbstractFactory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
|
||||
import { Collection } from '../../util'
|
||||
|
||||
/**
|
||||
* Abstract base class for dependency container factories.
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class AbstractFactory<T> {
|
||||
protected constructor(
|
||||
/**
|
||||
* Token that was registered for this factory. In most cases, this is the static
|
||||
* form of the item that is to be produced by this factory.
|
||||
* @var
|
||||
* @protected
|
||||
*/
|
||||
protected token: DependencyKey,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Produce an instance of the token.
|
||||
* @param {Array} dependencies - the resolved dependencies, in order
|
||||
* @param {Array} parameters - the bound constructor parameters, in order
|
||||
*/
|
||||
abstract produce(dependencies: any[], parameters: any[]): T
|
||||
|
||||
/**
|
||||
* Should return true if the given identifier matches the token for this factory.
|
||||
* @param something
|
||||
* @return boolean
|
||||
*/
|
||||
abstract match(something: unknown): boolean
|
||||
|
||||
/**
|
||||
* Get the dependency requirements required by this factory's token.
|
||||
* @return Collection<DependencyRequirement>
|
||||
*/
|
||||
abstract getDependencyKeys(): Collection<DependencyRequirement>
|
||||
|
||||
/**
|
||||
* Get the property dependencies that should be injected to the created instance.
|
||||
* @return Collection<PropertyDependency>
|
||||
*/
|
||||
abstract getInjectedProperties(): Collection<PropertyDependency>
|
||||
}
|
||||
43
src/di/factory/ClosureFactory.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {AbstractFactory} from './AbstractFactory'
|
||||
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
|
||||
import {Collection} from '../../util'
|
||||
|
||||
/**
|
||||
* A factory whose token is produced by calling a function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* let i = 0
|
||||
* const fact = new ClosureFactory('someName', () => {
|
||||
* i += 1
|
||||
* return i * 2
|
||||
* })
|
||||
*
|
||||
* fact.produce([], []) // => 2
|
||||
* fact.produce([], []) // => 4
|
||||
* ```
|
||||
*/
|
||||
export class ClosureFactory<T> extends AbstractFactory<T> {
|
||||
constructor(
|
||||
protected readonly name: DependencyKey,
|
||||
protected readonly token: () => T,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(): any {
|
||||
return this.token()
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === this.name
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return new Collection<PropertyDependency>()
|
||||
}
|
||||
}
|
||||
69
src/di/factory/Factory.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {AbstractFactory} from './AbstractFactory'
|
||||
import {
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
DependencyRequirement,
|
||||
Instantiable,
|
||||
PropertyDependency,
|
||||
} from '../types'
|
||||
import {Collection} from '../../util'
|
||||
import 'reflect-metadata'
|
||||
|
||||
/**
|
||||
* Standard static-class factory. The token of this factory is a reference to a
|
||||
* static class that is instantiated when the factory produces.
|
||||
*
|
||||
* Dependency keys are inferred from injection metadata on the constructor's params,
|
||||
* as are the injected properties.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A {
|
||||
* constructor(
|
||||
* protected readonly myService: MyService
|
||||
* ) { }
|
||||
* }
|
||||
*
|
||||
* const fact = new Factory(A)
|
||||
*
|
||||
* fact.produce([myServiceInstance], []) // => A { myService: myServiceInstance }
|
||||
* ```
|
||||
*/
|
||||
export class Factory<T> extends AbstractFactory<T> {
|
||||
constructor(
|
||||
protected readonly token: Instantiable<T>,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(dependencies: any[], parameters: any[]): any {
|
||||
return new this.token(...dependencies, ...parameters)
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === this.token // || (something?.name && something.name === this.token.name)
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.token)
|
||||
if ( meta ) {
|
||||
return meta
|
||||
}
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
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
|
||||
}
|
||||
}
|
||||
29
src/di/factory/NamedFactory.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {Factory} from './Factory'
|
||||
import {Instantiable} from '../types'
|
||||
|
||||
/**
|
||||
* Container factory that produces an instance of the token, however the token
|
||||
* is identified by a string name rather than a class reference.
|
||||
* @extends Factory
|
||||
*/
|
||||
export default class NamedFactory<T> extends Factory<T> {
|
||||
constructor(
|
||||
/**
|
||||
* The name identifying this factory in the container.
|
||||
* @type {string}
|
||||
*/
|
||||
protected name: string,
|
||||
|
||||
/**
|
||||
* The token to be instantiated.
|
||||
* @type {Instantiable}
|
||||
*/
|
||||
protected token: Instantiable<T>,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
match(something: unknown): boolean {
|
||||
return something === this.name
|
||||
}
|
||||
}
|
||||
48
src/di/factory/SingletonFactory.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Factory } from './Factory'
|
||||
import { Collection } from '../../util'
|
||||
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
|
||||
|
||||
/**
|
||||
* Container factory which returns its token as its value, without attempting
|
||||
* to instantiate anything. This is used to register already-produced-singletons
|
||||
* with the container.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A {}
|
||||
* const exactlyThisInstanceOfA = new A()
|
||||
*
|
||||
* const fact = new SingletonFactory(A, a)
|
||||
*
|
||||
* fact.produce([], []) // => exactlyThisInstanceOfA
|
||||
* ```
|
||||
*
|
||||
* @extends Factory
|
||||
*/
|
||||
export default class SingletonFactory<T> extends Factory<T> {
|
||||
constructor(
|
||||
/**
|
||||
* Token identifying this singleton.
|
||||
*/
|
||||
protected token: DependencyKey,
|
||||
|
||||
/**
|
||||
* The value of this singleton.
|
||||
*/
|
||||
protected value: T,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(): T {
|
||||
return this.value
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return new Collection<PropertyDependency>()
|
||||
}
|
||||
}
|
||||