57 Commits

Author SHA1 Message Date
6fc901b3ec bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 17:05:22 -06:00
50e0cf3090 Fix prototype access issue with model scopes property
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 17:02:43 -06:00
d245d15ad6 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 16:52:35 -06:00
265837b5cd Fix stupid typescript error...
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 16:50:01 -06:00
fe0b4d6d8f bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-11-25 16:39:53 -06:00
ce1d22ff44 Make the linter happy
Some checks failed
continuous-integration/drone/push Build is failing
2021-11-25 16:39:25 -06:00
b7bfb3e153 Model: fix eager-loaded relation loading from static query 2021-11-25 16:39:17 -06:00
e57819d318 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-15 14:00:29 -06:00
0a9dd30909 Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-11 16:42:37 -06:00
d92c8b5409 Start implementation of model relations
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-10 21:30:59 -06:00
589cb7d579 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 22:29:34 -05:00
3680ad1914 Fix back-fill on insert for Model.save 2021-10-19 22:29:15 -05:00
96e13d85fc Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:59:14 -05:00
5a9283ad85 orm(PostgreSQLDialect): double-quote column names in INSERT field lists 2021-10-19 13:59:00 -05:00
b1ea489ccb Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:00:27 -05:00
c3f2779650 Add generic to APIResponse 2021-10-19 13:00:14 -05:00
248b24e612 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 10:26:47 -05:00
b4a9057e2b CLI invocation output better debugging infor 2021-10-19 10:26:32 -05:00
c078d695a8 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-18 17:23:30 -05:00
55ffadc742 Export CLI decorators
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 17:23:16 -05:00
56574d43ce Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:57:44 -05:00
e16f02ce12 Readd migrations
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 14:49:19 -05:00
c34fad3502 Fix path in drone docs deploy
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-10-18 14:02:00 -05:00
156006053b Fix path in drone static deploy
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is failing
2021-10-18 13:53:37 -05:00
22cf6aa953 bump version
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is failing
2021-10-18 13:41:22 -05:00
b35eb8d6a1 Fix error throw
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 13:36:59 -05:00
9ee4c42e43 Error type fixes
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 13:03:28 -05:00
8d1dcc87fb Bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-10-18 12:49:56 -05:00
3efbfecf9d OAuth2 stuff
Some checks failed
continuous-integration/drone/push Build is failing
2021-10-18 12:48:16 -05:00
a1d04d652e Implement basic login & registration forms
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 22:25:51 -05:00
5940b6e2b3 Fix circular dependencies in migrator
Some checks failed
continuous-integration/drone/push Build is failing
2021-09-21 13:42:06 -05:00
074a3187eb Add support for jobs & queueables, migrations
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
- Create migration directives & migrators
- Modify Cache classes to support array manipulation
- Create Redis unit and RedisCache implementation
- Create Queueable base class and Queue class that uses Cache backend
2021-08-23 23:51:53 -05:00
26e0444e40 version 0.5.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-25 09:15:41 -05:00
fcce28081b AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-25 09:15:01 -05:00
e86cf420df Named routes & basic login framework 2021-07-17 12:49:07 -05:00
e33d8dee8f Add support for registering vendor asset routes
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-07 22:50:48 -05:00
39d97d6e14 version 0.4.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-07 20:15:36 -05:00
f496046461 File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing
- Clean up UniversalPath implementation
    - Use Readable/Writable types correctly for stream methods
    - Add .list() methods for getting child files

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

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

View File

@@ -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
View File

@@ -0,0 +1,3 @@
node_modules
lib
dist

113
.eslintrc.json Normal file
View 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
View File

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

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

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

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

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

1
.idea/lib.iml generated
View File

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

1
.idea/modules.xml generated
View File

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

6
.idea/vcs.xml generated Normal file
View 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
View File

@@ -0,0 +1 @@
www*

31
docs/HOME.md Normal file
View 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.

View File

@@ -0,0 +1 @@
# About the Extollo Project

View 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
View File

@@ -0,0 +1,6 @@
[
{
"pattern": "^",
"replace": "https://code.garrettmills.dev/extollo/lib/src/branch/master/"
}
]

BIN
docs/static/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

19
docs/static/humans.txt vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

64
docs/theme/assets/css/pages.css vendored Normal file
View 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

Binary file not shown.

Binary file not shown.

BIN
docs/theme/assets/images/icons.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
docs/theme/assets/images/icons@2x.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

BIN
docs/theme/assets/images/widgets@2x.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

1
docs/theme/assets/js/main.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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}}&nbsp;
{{/ifCond}}
{{model.name}}
{{#if model.typeParameters}}
&lt;
{{#each model.typeParameters}}
{{#if @index}},&nbsp;{{/if}}
{{name}}
{{/each}}
&gt;
{{/if}}
{{/compact}}</h1>
</div>
</div>
</header>

View File

@@ -0,0 +1,3 @@
<div class="tsd-panel tsd-typography">
{{#markdown}}{{{model.pagesPlugin.item.contents}}}{{/markdown}}
</div>

1338
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.1.3",
"version": "0.5.12",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -8,21 +8,31 @@
"lib": "lib"
},
"dependencies": {
"@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",
@@ -30,14 +40,20 @@
"rimraf": "^3.0.2",
"ssh2": "^1.1.0",
"ts-node": "^9.1.1",
"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/**/*"
@@ -49,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
View 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"
}
]
}
]
}

2490
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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]
}
}
}

View File

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

151
src/auth/SecurityContext.ts Normal file
View 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)
}
}

View File

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

View File

@@ -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),
],
}
}
}

View File

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

29
src/auth/config.ts Normal file
View 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,
},
}

View 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())
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

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

View File

@@ -0,0 +1,155 @@
import {
Authenticatable,
AuthenticatableCredentials,
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(): Awaitable<Maybe<Authenticatable>> {
return undefined
}
public getRedirectUrl(state?: string): UniversalPath {
const url = universalPath(this.config.redirectUrl)
if ( state ) {
url.query.append('state', state)
}
return url
}
public getTokenEndpoint(): UniversalPath {
return universalPath(this.config.tokenEndpoint)
}
public getUserEndpoint(): UniversalPath {
return universalPath(this.config.userEndpoint)
}
public async redeem(): Promise<Maybe<OAuth2User>> {
if ( !this.stateIsValid() ) {
return // FIXME throw
}
const body = new URLSearchParams()
if ( this.config.tokenEndpointMapping ) {
if ( this.config.tokenEndpointMapping.clientId ) {
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
}
if ( this.config.tokenEndpointMapping.clientSecret ) {
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
}
if ( this.config.tokenEndpointMapping.codeKey ) {
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
}
if ( this.config.tokenEndpointMapping.grantType ) {
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
}
}
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
const response = await fetch(this.getTokenEndpoint().toRemote, {
method: 'post',
body: body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
this.logging.debug(data)
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
this.logging.debug(bearer)
if ( !bearer || typeof bearer !== 'string' ) {
throw new Error()
}
return this.getAuthenticatableFromBearer(bearer)
}
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
const response = await fetch(this.getUserEndpoint().toRemote, {
method: 'get',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearer}`,
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
return new OAuth2User(data, this.config)
}
public stateIsValid(): boolean {
const correctState = this.session.get('extollo.auth.oauth2.state', '')
const inputState = this.request.input('state') || ''
return correctState === inputState
}
public shouldRedirect(): boolean {
const codeField = this.config.authorizationCodeField
const code = this.request.input(codeField)
return !code
}
public async redirect(): Promise<ResponseObject> {
const state = uuid4()
await this.session.set('extollo.auth.oauth2.state', state)
return temporary(this.getRedirectUrl(state).toRemote)
}
}

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

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

26
src/auth/index.ts Normal file
View 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'

View 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)
}
}
}
}

View 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)
}
}
}
}

View 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
View 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)
}
}

View 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
View File

@@ -0,0 +1,43 @@
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
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>
}

View File

@@ -1,10 +1,10 @@
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";
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.
@@ -35,7 +35,7 @@ export abstract class Directive extends AppClass {
protected readonly logging!: Logging
/** Parsed option values. */
private _optionValues: any
private optionValues: any
/**
* Get the keyword or array of keywords that will specify this directive.
@@ -84,8 +84,8 @@ export abstract class Directive extends AppClass {
* @param optionValues
* @private
*/
private _setOptionValues(optionValues: any) {
this._optionValues = optionValues;
private setOptionValues(optionValues: any) {
this.optionValues = optionValues
}
/**
@@ -93,9 +93,9 @@ export abstract class Directive extends AppClass {
* @param name
* @param defaultValue
*/
public option(name: string, defaultValue?: any) {
if ( name in this._optionValues ) {
return this._optionValues[name]
public option(name: string, defaultValue?: unknown): any {
if ( name in this.optionValues ) {
return this.optionValues[name]
}
return defaultValue
@@ -110,20 +110,28 @@ export abstract class Directive extends AppClass {
*
* @param argv
*/
async invoke(argv: string[]) {
async invoke(argv: string[]): Promise<void> {
const options = this.getResolvedOptions()
if ( this.didRequestUsage(argv) ) {
// @ts-ignore
const positionalArguments: PositionalOption<any>[] = options.filter(opt => opt instanceof PositionalOption)
const positionalArguments: PositionalOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof PositionalOption ) {
positionalArguments.push(opt)
}
})
// @ts-ignore
const flagArguments: FlagOption<any>[] = options.filter(opt => opt instanceof FlagOption)
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]' : ''
console.log([
this.nativeOutput([
'',
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
'',
@@ -131,7 +139,7 @@ export abstract class Directive extends AppClass {
].join('\n'))
if ( positionalArguments.length ) {
console.log([
this.nativeOutput([
'',
`POSITIONAL ARGUMENTS:`,
...(positionalArguments.map(arg => {
@@ -141,7 +149,7 @@ export abstract class Directive extends AppClass {
}
if ( flagArguments.length ) {
console.log([
this.nativeOutput([
'',
`FLAGS:`,
...(flagArguments.map(arg => {
@@ -152,34 +160,39 @@ export abstract class Directive extends AppClass {
const help = this.getHelpText()
if ( help ) {
console.log('\n' + help)
this.nativeOutput('\n' + help)
}
console.log('\n')
this.nativeOutput('\n')
} else {
try {
const optionValues = this.parseOptions(options, argv)
this._setOptionValues(optionValues)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e) {
console.error(e.message)
} catch (e: unknown) {
if ( e instanceof Error ) {
this.nativeOutput(e.message)
this.error(e)
}
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
console.error(` - Expecting: ${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 ) {
console.error(` - ${req}`)
this.nativeOutput(` - ${req}`)
}
}
if ( e.context.value ) {
console.error(` - ${e.context.value}`)
this.nativeOutput(` - ${e.context.value}`)
}
}
console.error('\nUse --help for more info.')
this.nativeOutput('\nUse --help for more info.')
}
}
}
@@ -217,9 +230,11 @@ export abstract class Directive extends AppClass {
* Returns true if the given keyword should invoke this directive.
* @param name
*/
public matchesKeyword(name: string) {
public matchesKeyword(name: string): boolean {
let kws = this.getKeywords()
if ( !Array.isArray(kws) ) kws = [kws]
if ( !Array.isArray(kws) ) {
kws = [kws]
}
return kws.includes(name)
}
@@ -227,7 +242,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as success text.
* @param output
*/
success(output: any) {
success(output: unknown): void {
this.logging.success(output, true)
}
@@ -235,7 +250,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as error text.
* @param output
*/
error(output: any) {
error(output: unknown): void {
this.logging.error(output, true)
}
@@ -243,7 +258,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as warning text.
* @param output
*/
warn(output: any) {
warn(output: unknown): void {
this.logging.warn(output, true)
}
@@ -251,7 +266,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as info text.
* @param output
*/
info(output: any) {
info(output: unknown): void {
this.logging.info(output, true)
}
@@ -259,7 +274,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as debugging text.
* @param output
*/
debug(output: any) {
debug(output: unknown): void {
this.logging.debug(output, true)
}
@@ -267,7 +282,7 @@ export abstract class Directive extends AppClass {
* Print the given output to the log as verbose text.
* @param output
*/
verbose(output: any) {
verbose(output: unknown): void {
this.logging.verbose(output, true)
}
@@ -275,7 +290,7 @@ export abstract class Directive extends AppClass {
* Get the flag option that signals help. Usually, this is named 'help'
* and supports the flags '--help' and '-?'.
*/
getHelpOption() {
getHelpOption(): FlagOption<any> {
return new FlagOption('--help', '-?', 'usage information about this directive')
}
@@ -283,12 +298,21 @@ export abstract class Directive extends AppClass {
* 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[]) {
// @ts-ignore
let positionalArguments: PositionalOption<any>[] = options.filter(cls => cls instanceof PositionalOption)
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)
}
})
// @ts-ignore
const flagArguments: FlagOption<any>[] = options.filter(cls => cls instanceof FlagOption)
const optionValue: any = {}
flagArguments.push(this.getHelpOption())
@@ -325,7 +349,7 @@ export abstract class Directive extends AppClass {
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
if ( flagArgument.length < 1 ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
value
value,
})
} else {
if ( flagArgument[0].argumentDescription ) {
@@ -350,7 +374,7 @@ export abstract class Directive extends AppClass {
} else {
if ( positionalArguments.length < 1 ) {
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
value
value,
})
} else {
const inferredValue = infer(value)
@@ -368,13 +392,13 @@ export abstract class Directive extends AppClass {
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
expecting: positionalFlagName
expecting: positionalFlagName,
})
}
if ( positionalArguments.length > 0 ) {
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
expecting: positionalArguments[0].getArgumentName()
expecting: positionalArguments[0].getArgumentName(),
})
}
@@ -430,14 +454,18 @@ export abstract class Directive extends AppClass {
* 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[]) {
const help_option = this.getHelpOption()
didRequestUsage(argv: string[]): boolean {
const helpOption = this.getHelpOption()
for ( const arg of argv ) {
if ( arg.trim() === help_option.longFlag || arg.trim() === help_option.shortFlag ) {
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
}
}

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

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

View File

@@ -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)'
}
}

View 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)
}
}

View File

@@ -1,8 +1,8 @@
import {Directive} from "../Directive"
import {CommandLineApplication} from "../service"
import {Injectable} from "../../di"
import {ErrorWithContext} from "../../util"
import {Unit} from "../../lifecycle/Unit";
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.

View File

@@ -1,7 +1,7 @@
import {Directive} from "../Directive"
import * as colors from "colors/safe"
import {Directive} from '../Directive'
import * as colors from 'colors/safe'
import * as repl from 'repl'
import {DependencyKey} from "../../di";
import {DependencyKey} from '../../di'
/**
* Launch an interactive REPL shell from within the application.
@@ -9,7 +9,7 @@ import {DependencyKey} from "../../di";
*/
export class ShellDirective extends Directive {
protected options: any = {
welcome: `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
}
@@ -34,11 +34,12 @@ export class ShellDirective extends Directive {
async handle(): Promise<void> {
const state: any = {
app: this.app(),
lib: await import('../../index'),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
}
await new Promise<void>(res => {
console.log(this.options.welcome)
this.nativeOutput(this.options.welcome)
this.repl = repl.start(this.options.prompt)
Object.assign(this.repl.context, state)
this.repl.on('exit', () => res())

View File

@@ -1,8 +1,8 @@
import {Directive, OptionDefinition} from "../Directive"
import {PositionalOption} from "./options/PositionalOption"
import {CommandLine} from "../service"
import {Inject, Injectable} from "../../di";
import {ErrorWithContext} from "../../util";
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.
@@ -46,11 +46,11 @@ export class TemplateDirective extends Directive {
'',
...(registeredTemplates.map(template => {
return ` - ${template.name}: ${template.description}`
}).all())
}).all()),
].join('\n')
}
async handle(argv: string[]) {
async handle(): Promise<void> {
const templateName: string = this.option('template_name')
const destinationName: string = this.option('file_name')

View File

@@ -1,7 +1,7 @@
import {Directive} from "../Directive"
import {Injectable, Inject} from "../../di"
import {padRight} from "../../util"
import {CommandLine} from "../service"
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
@@ -20,7 +20,7 @@ export class UsageDirective extends Directive {
return 'print information about available commands'
}
public handle(argv: string[]): void | Promise<void> {
public handle(): void | Promise<void> {
const directiveStrings = this.cli.getDirectives()
.map(cls => this.make<Directive>(cls))
.map<[string, string]>(dir => {
@@ -30,15 +30,15 @@ export class UsageDirective extends Directive {
const maxLen = directiveStrings.max<number>(x => x[0].length)
const printStrings = directiveStrings.map(grp => {
return [padRight(grp[0], maxLen + 1), grp[1]]
})
return [padRight(grp[0], maxLen + 1), grp[1]]
})
.map(grp => {
return ` ${grp[0]}: ${grp[1]}`
})
.toArray()
console.log(this.cli.getASCIILogo())
console.log([
this.nativeOutput(this.cli.getASCIILogo())
this.nativeOutput([
'',
'Welcome to Extollo! Specify a command to get started.',
'',
@@ -48,7 +48,7 @@ export class UsageDirective extends Directive {
'',
'For usage information about a particular command, pass the --help flag.',
'-------------------------------------------',
`powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills`,
`powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills`,
].join('\n'))
}
}

View File

@@ -9,200 +9,207 @@ export abstract class CLIOption<T> {
* @type {boolean}
* @private
*/
protected _useWhitelist: boolean = false
protected useWhitelist = false
/**
* Do we use the blacklist?
* @type {boolean}
* @private
*/
protected _useBlacklist: boolean = false
protected useBlacklist = false
/**
* Do we use the less-than comparison?
* @type {boolean}
* @private
*/
protected _useLessThan: boolean = false
protected useLessThan = false
/**
* Do we use the greater-than comparison?
* @type {boolean}
* @private
*/
protected _useGreaterThan: boolean = false
protected useGreaterThan = false
/**
* Do we use the equality operator?
* @type {boolean}
* @private
*/
protected _useEquality: boolean = false
protected useEquality = false
/**
* Is this option optional?
* @type {boolean}
* @private
*/
protected _optional: boolean = false
protected isOptional = false
/**
* Whitelisted values.
* @type {Array<*>}
* @private
*/
protected _whitelist: T[] = []
protected whitelistItems: T[] = []
/**
* Blacklisted values.
* @type {Array<*>}
* @private
*/
protected _blacklist: T[] = []
protected blacklistItems: T[] = []
/**
* Value to be compared in less than.
* @type {*}
* @private
*/
protected _lessThanValue?: T
protected lessThanValue?: T
/**
* If true, the less than will be less than or equal to.
* @type {boolean}
* @private
*/
protected _lessThanBit: boolean = false
protected lessThanBit = false
/**
* Value to be compared in greater than.
* @type {*}
* @private
*/
protected _greaterThanValue?: T
protected greaterThanValue?: T
/**
* If true, the greater than will be greater than or equal to.
* @type {boolean}
* @private
*/
protected _greateerThanBit: boolean = false
protected greaterThanBit = false
/**
* The value to be used to check equality.
* @type {*}
* @private
*/
protected _equalityValue?: T
protected equalityValue?: T
/**
* Whitelist the specified item or items and enable the whitelist.
* @param {...*} items - the items to whitelist
*/
whitelist(...items: T[]) {
this._useWhitelist = true
items.forEach(item => this._whitelist.push(item))
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._useBlacklist = true
items.forEach(item => this._blacklist.push(item))
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._useLessThan = true
this._lessThanValue = 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._lessThanBit = true
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._useGreaterThan = true
this._greaterThanValue = 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._greateerThanBit = true
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._useEquality = true
this._equalityValue = value
equals(value: T): this {
this.useEquality = true
this.equalityValue = value
return this
}
/**
* Checks if the specified value passes the configured comparisons.
* @param {*} value
* @param value
* @returns {boolean}
*/
validate(value: any) {
let is_valid = true
if ( this._useEquality ) {
is_valid = is_valid && (this._equalityValue === value)
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 ) {
is_valid = is_valid && (value <= this._lessThanValue)
if ( this.useLessThan && typeof this.lessThanValue !== 'undefined' ) {
if ( this.lessThanBit ) {
isValid = isValid && (value <= this.lessThanValue)
} else {
is_valid = is_valid && (value < this._lessThanValue)
isValid = isValid && (value < this.lessThanValue)
}
}
if ( this._useGreaterThan && typeof this._greaterThanValue !== 'undefined' ) {
if ( this._greateerThanBit ) {
is_valid = is_valid && (value >= this._greaterThanValue)
if ( this.useGreaterThan && typeof this.greaterThanValue !== 'undefined' ) {
if ( this.greaterThanBit ) {
isValid = isValid && (value >= this.greaterThanValue)
} else {
is_valid = is_valid && (value > this._greaterThanValue)
isValid = isValid && (value > this.greaterThanValue)
}
}
if ( this._useWhitelist ) {
is_valid = is_valid && this._whitelist.some(x => {
if ( this.useWhitelist ) {
isValid = isValid && this.whitelistItems.some(x => {
return x === value
})
}
if ( this._useBlacklist ) {
is_valid = is_valid && !(this._blacklist.some(x => x === value))
if ( this.useBlacklist ) {
isValid = isValid && !(this.blacklistItems.some(x => x === value))
}
return is_valid
return isValid
}
/**
* Sets the Option as optional.
*/
optional() {
this._optional = true
optional(): this {
this.isOptional = true
return this
}
@@ -216,27 +223,27 @@ export abstract class CLIOption<T> {
* Get an array of strings denoting the human-readable requirements for this option to be valid.
* @returns {Array<string>}
*/
getRequirementDisplays() {
getRequirementDisplays(): string[] {
const clauses = []
if ( this._useBlacklist ) {
clauses.push(`must not be one of: ${this._blacklist.map(x => String(x)).join(', ')}`)
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._whitelist.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._greateerThanBit ? ' or equal to' : ''}: ${String(this._greaterThanValue)}`)
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.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)}`)
if ( this.useEquality ) {
clauses.push(`must be equal to: ${String(this.equalityValue)}`)
}
return clauses

View File

@@ -1,4 +1,4 @@
import {CLIOption} from "./CLIOption"
import {CLIOption} from './CLIOption'
/**
* Non-positional, flag-based CLI option.
@@ -24,8 +24,10 @@ export class FlagOption<T> extends CLIOption<T> {
* 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() }
public readonly argumentDescription?: string,
) {
super()
}
/**
* Get the referential name for this option.
@@ -33,7 +35,7 @@ export class FlagOption<T> extends CLIOption<T> {
* be found, the short flag (without the '-') is used.
* @returns {string}
*/
getArgumentName() {
getArgumentName(): string {
if ( this.longFlag ) {
return this.longFlag.replace('--', '')
} else if ( this.shortFlag ) {

View File

@@ -1,4 +1,4 @@
import {CLIOption} from "./CLIOption"
import {CLIOption} from './CLIOption'
/**
* A positional CLI option. Defined without a flag.
@@ -19,14 +19,16 @@ export class PositionalOption<T> extends CLIOption<T> {
/**
* A usage message describing this parameter.
*/
public readonly message: string = ''
) { super() }
public readonly message: string = '',
) {
super()
}
/**
* Gets the name of the option.
* @returns {string}
*/
getArgumentName () {
getArgumentName(): string {
return this.name
}
}

View File

@@ -11,3 +11,5 @@ export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective'
export * from './directive/TemplateDirective'
export * from './directive/UsageDirective'
export * from './decorators'

View File

@@ -1,16 +1,16 @@
import {Singleton, Instantiable, Inject} from "../../di"
import {Collection} from "../../util"
import {CommandLineApplication} from "./CommandLineApplication"
import {Directive} from "../Directive"
import {Template} from "../Template"
import {directive_template} from "../templates/directive"
import {unit_template} from "../templates/unit";
import {controller_template} from "../templates/controller";
import {middleware_template} from "../templates/middleware";
import {routes_template} from "../templates/routes";
import {config_template} from "../templates/config";
import {Unit} from "../../lifecycle/Unit";
import {Logging} from "../../service/Logging";
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
@@ -27,28 +27,30 @@ export class CommandLine extends Unit {
/** Templates registered with the CLI command. These can be created with the TemplateDirective. */
protected templates: Collection<Template> = new Collection<Template>()
constructor() { super() }
constructor() {
super()
}
async up() {
this.registerTemplate(directive_template)
this.registerTemplate(unit_template)
this.registerTemplate(controller_template)
this.registerTemplate(middleware_template)
this.registerTemplate(routes_template)
this.registerTemplate(config_template)
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() {
public isCLI(): boolean {
return this.app().hasUnit(CommandLineApplication)
}
/**
* Returns a string containing the Extollo ASCII logo.
*/
public getASCIILogo() {
public getASCIILogo(): string {
return ` _
/ /\\ ______ _ _ _
/ / \\ | ____| | | | | |
@@ -64,24 +66,26 @@ export class CommandLine extends Unit {
* the directive available for use on the CLI.
* @param directiveClass
*/
public registerDirective(directiveClass: Instantiable<Directive>) {
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>) {
public hasDirective(directiveClass: Instantiable<Directive>): boolean {
return this.directives.includes(directiveClass)
}
/**
* Get a collection of all registered directives.
*/
public getDirectives() {
public getDirectives(): Collection<Instantiable<Directive>> {
return this.directives.clone()
}
@@ -90,21 +94,23 @@ export class CommandLine extends Unit {
* available for use with the TemplateDirective service.
* @param template
*/
public registerTemplate(template: 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) {
return !!this.templates.firstWhere('name', '=', name)
public hasTemplate(name: string): boolean {
return Boolean(this.templates.firstWhere('name', '=', name))
}
/**
@@ -118,7 +124,7 @@ export class CommandLine extends Unit {
/**
* Get a collection of all registered templates.
*/
public getTemplates() {
public getTemplates(): Collection<Template> {
return this.templates.clone()
}
}

View File

@@ -1,12 +1,14 @@
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 {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
@@ -18,12 +20,12 @@ export class CommandLineApplication extends Unit {
private static replacement?: typeof Unit
/** Set the replaced unit. */
public static setReplacement(unitClass?: typeof Unit) {
public static setReplacement(unitClass?: typeof Unit): void {
this.replacement = unitClass
}
/** Get the replaced unit. */
public static getReplacement() {
public static getReplacement(): typeof Unit | undefined {
return this.replacement
}
@@ -33,13 +35,17 @@ export class CommandLineApplication extends Unit {
@Inject()
protected readonly logging!: Logging
constructor() { super() }
constructor() {
super()
}
public async up() {
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()
@@ -50,7 +56,7 @@ export class CommandLineApplication extends Unit {
await match.invoke(argv.slice(1))
} else {
const usage = this.make<UsageDirective>(UsageDirective)
await usage.handle(process.argv)
await usage.handle()
}
}
}

View File

@@ -1,22 +1,21 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* A template that generates a new configuration file in the app/configs directory.
*/
const config_template: Template = {
const templateConfig: Template = {
name: 'config',
fileSuffix: '.config.ts',
description: 'Create a new config file.',
baseAppPath: ['configs'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
render() {
return `import { env } from '@extollo/lib'
export default {
key: env('VALUE_ENV_VAR', 'default value'),
}
`
}
},
}
export { config_template }
export { templateConfig }

View File

@@ -1,17 +1,15 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* Template that generates a new controller in the app/http/controllers directory.
*/
const controller_template: Template = {
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, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Controller, view} from "@extollo/lib"
import {Inject, Injectable} from "@extollo/di"
render(name: string) {
return `import {Controller, view, Inject, Injectable} from '@extollo/lib'
/**
* ${name} Controller
@@ -25,7 +23,7 @@ export class ${name} extends Controller {
}
}
`
}
},
}
export { controller_template }
export { templateController }

View File

@@ -1,17 +1,15 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* Template that generates a new Directive class in the app/directives directory.
*/
const directive_template: Template = {
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, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Directive, OptionDefinition} from "@extollo/cli"
import {Injectable} from "@extollo/di"
render(name: string) {
return `import {Directive, OptionDefinition, Injectable} from '@extollo/lib'
/**
* ${name} Directive
@@ -37,7 +35,7 @@ export class ${name}Directive extends Directive {
}
}
`
}
},
}
export { directive_template }
export { templateDirective }

View File

@@ -1,17 +1,15 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* Template that generates a new middleware class in app/http/middlewares.
*/
const middleware_template: Template = {
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, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Middleware} from "@extollo/lib"
import {Injectable} from "@extollo/di"
render(name: string) {
return `import {Middleware, Injectable} from '@extollo/lib'
/**
* ${name} Middleware
@@ -25,7 +23,7 @@ export class ${name} extends Middleware {
}
}
`
}
},
}
export { middleware_template }
export { templateMiddleware }

View File

@@ -1,16 +1,15 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* Template that generates a new route definition file in app/http/routes.
*/
const routes_template: Template = {
const templateRoutes: Template = {
name: 'routes',
fileSuffix: '.routes.ts',
description: 'Create a file for route definitions.',
baseAppPath: ['http', 'routes'],
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Route} from "@extollo/lib"
render(name: string) {
return `import {Route} from '@extollo/lib'
/*
* ${name} Routes
@@ -20,7 +19,7 @@ const routes_template: Template = {
`
}
},
}
export { routes_template }
export { templateRoutes }

View File

@@ -1,17 +1,15 @@
import {Template} from "../Template"
import {UniversalPath} from "../../util"
import {Template} from '../Template'
/**
* Template that generates a new application unit class in app/units.
*/
const unit_template: Template = {
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, fullCanonicalName: string, targetFilePath: UniversalPath) {
return `import {Singleton, Inject} from "@extollo/di"
import {Unit, Logging} from "@extollo/lib"
render(name: string) {
return `import {Singleton, Inject, Unit, Logging} from '@extollo/lib'
/**
* ${name} Unit
@@ -32,7 +30,7 @@ export class ${name} extends Unit {
}
}
`
}
},
}
export { unit_template }
export { templateUnit }

View File

@@ -1,14 +1,15 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} 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 {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 = AbstractFactory | undefined
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
@@ -16,13 +17,36 @@ export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resol
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Given a Container instance, apply the ContainerBlueprint to it.
* @param container
*/
public static realizeContainer<T extends Container>(container: T): T {
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
ContainerBlueprint.getContainerBlueprint()
.resolveConstructable()
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
ContainerBlueprint.getContainerBlueprint()
.resolveResolutionCallbacks()
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
container.onResolve(listener.key)
.then(value => listener.callback(value))
})
return container
}
/**
* Get the global instance of this container.
*/
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Container()
const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -34,7 +58,7 @@ export class Container {
* Collection of factories registered with this container.
* @type Collection<AbstractFactory>
*/
protected factories: Collection<AbstractFactory> = new Collection<AbstractFactory>()
protected factories: Collection<AbstractFactory<unknown>> = new Collection<AbstractFactory<unknown>>()
/**
* Collection of singleton instances produced by this container.
@@ -42,21 +66,48 @@ export class Container {
*/
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
/**
* Collection of callbacks waiting for a dependency key to be resolved.
* @protected
*/
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>();
constructor() {
this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this)
}
/**
* 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>) {
if ( this.resolve(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
}
/**
@@ -64,12 +115,14 @@ export class Container {
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: string | StaticClass<any, any>, producer: () => any) {
if ( this.resolve(name) )
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
}
/**
@@ -78,12 +131,14 @@ export class Container {
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>) {
if ( this.resolve(name) )
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
}
/**
@@ -92,11 +147,13 @@ export class Container {
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton(key: string, value: any) {
if ( this.resolve(key) )
registerSingleton<T>(key: DependencyKey, value: T): this {
if ( this.resolve(key) ) {
throw new DuplicateFactoryKeyError(key)
}
this.factories.push(new SingletonFactory(value, key))
this.factories.push(new SingletonFactory(key, value))
return this
}
/**
@@ -105,24 +162,30 @@ export class Container {
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T) {
if ( this.resolve(staticClass) )
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) {
if ( !this.factories.includes(factory) )
registerFactory(factory: AbstractFactory<unknown>): this {
if ( !this.factories.includes(factory) ) {
this.factories.push(factory)
}
return this
}
/**
@@ -133,12 +196,32 @@ export class Container {
return this.instances.where('key', '=', key).isNotEmpty()
}
/**
* Get a Promise that resolves the first time the given dependency key is resolved
* by the application. If it has already been resolved, the Promise will resolve immediately.
* @param key
*/
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
if ( this.hasInstance(key) ) {
return new Promise<T>(res => res(this.make<T>(key)))
}
// Otherwise, we haven't instantiated an instance with this key yet,
// so put it onto the waitlist.
return new Promise<T>(res => {
this.waitingResolveCallbacks.push({
key,
callback: (res as (t: unknown) => unknown),
})
})
}
/**
* Returns true if the container has a factory for the given key.
* @param {DependencyKey} key
*/
hasKey(key: DependencyKey): boolean {
return !!this.resolve(key)
return Boolean(this.resolve(key))
}
/**
@@ -147,17 +230,22 @@ export class Container {
*/
getExistingInstance(key: DependencyKey): MaybeDependency {
const instances = this.instances.where('key', '=', key)
if ( instances.isNotEmpty() ) return instances.first()
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 {
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)
if ( factory ) {
return factory
} else {
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
}
}
/**
@@ -172,22 +260,34 @@ export class Container {
// 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
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 )
if ( !factory ) {
throw new InvalidDependencyKeyError(key)
}
// Produce and store a new instance
const new_instance = this.produceFactory(factory, parameters)
const newInstance = this.produceFactory(factory, parameters)
this.instances.push({
key,
value: new_instance,
value: newInstance,
})
return new_instance
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
if ( waiter.key === key ) {
waiter.callback(newInstance)
return false
}
return true
})
return newInstance
}
/**
@@ -196,7 +296,7 @@ export class Container {
* @param {AbstractFactory} factory
* @param {array} parameters
*/
protected produceFactory(factory: AbstractFactory, parameters: any[]) {
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 => {
@@ -210,20 +310,23 @@ export class Container {
// Build the arguments for the factory, using dependencies in the
// correct paramIndex positions, or parameters of we don't have
// the dependency.
const construction_args = []
let params = collect(parameters).reverse()
const constructorArguments = []
const params = collect(parameters).reverse()
for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) {
const dep = dependencies.firstWhere('paramIndex', '=', i)
if ( dep ) construction_args.push(dep.resolved)
else construction_args.push(params.pop())
if ( dep ) {
constructorArguments.push(dep.resolved)
} else {
constructorArguments.push(params.pop())
}
}
// Produce a new instance
const inst = factory.produce(construction_args, params.reverse().all())
const inst = factory.produce(constructorArguments, params.reverse().all())
factory.getInjectedProperties().each(dependency => {
if ( dependency.key && inst ) {
inst[dependency.property] = this.resolveAndCreate(dependency.key)
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
}
})
@@ -240,12 +343,13 @@ export class Container {
* @param {...any} parameters
*/
make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( this.hasKey(target) )
if ( this.hasKey(target) ) {
return this.resolveAndCreate(target, ...parameters)
else if ( typeof target !== 'string' && isInstantiable(target) )
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
return this.produceFactory(new Factory(target), parameters)
else
} else {
throw new TypeError(`Invalid or unknown make target: ${target}`)
}
}
/**
@@ -255,8 +359,9 @@ export class Container {
getDependencies(target: DependencyKey): Collection<DependencyKey> {
const factory = this.resolve(target)
if ( !factory )
if ( !factory ) {
throw new InvalidDependencyKeyError(target)
}
return factory.getDependencyKeys().pluck('key')
}
@@ -265,8 +370,9 @@ export class Container {
* Given a different container, copy the factories and instances from this container over to it.
* @param container
*/
cloneTo(container: Container) {
cloneTo(container: Container): this {
container.factories = this.factories.clone()
container.instances = this.instances.clone()
return this
}
}

View File

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

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import 'reflect-metadata'
import {collect, Collection} from "../../util";
import {collect, Collection, logIfDebugging} from '../../util'
import {
DependencyKey,
DependencyRequirement,
@@ -9,16 +9,16 @@ import {
InjectionType,
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
PropertyDependency,
} from "../types";
import {Container} from "../Container";
} 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: Object): Collection<DependencyRequirement> {
const paramTypes = Reflect.getMetadata('design:paramtypes', target)
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,
@@ -37,32 +37,32 @@ export const Injectable = (): ClassDecorator => {
return (target) => {
const meta = initDependencyMetadata(target)
const existing = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
const new_meta = new Collection<DependencyRequirement>()
const newMetadata = new Collection<DependencyRequirement>()
if ( existing ) {
const max_new = meta.max('paramIndex')
const max_existing = existing.max('paramIndex')
for ( let i = 0; i <= Math.max(max_new, max_existing); i++ ) {
const existing_dr = existing.firstWhere('paramIndex', '=', i)
const new_dr = meta.firstWhere('paramIndex', '=', i)
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 ( existing_dr && !new_dr ) {
new_meta.push(existing_dr)
} else if ( new_dr && !existing_dr ) {
new_meta.push(new_dr)
} else if ( new_dr && existing_dr ) {
if ( existing_dr.overridden ) {
new_meta.push(existing_dr)
if ( existingDR && !newDR ) {
newMetadata.push(existingDR)
} else if ( newDR && !existingDR ) {
newMetadata.push(newDR)
} else if ( newDR && existingDR ) {
if ( existingDR.overridden ) {
newMetadata.push(existingDR)
} else {
new_meta.push(new_dr)
newMetadata.push(newDR)
}
}
}
} else {
new_meta.concat(meta)
newMetadata.concat(meta)
}
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, new_meta, target)
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, newMetadata, target)
}
}
@@ -71,9 +71,10 @@ export const Injectable = (): ClassDecorator => {
* If a `key` is specified, that DependencyKey will be injected.
* Otherwise, the DependencyKey is inferred from the type annotation.
* @param key
* @param debug
* @constructor
*/
export const Inject = (key?: DependencyKey): PropertyDecorator => {
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
return (target, property) => {
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
if ( !propertyMetadata ) {
@@ -82,17 +83,27 @@ export const Inject = (key?: DependencyKey): PropertyDecorator => {
}
const type = Reflect.getMetadata('design:type', target, property)
if ( !key && type ) key = type
if ( !key && type ) {
key = type
}
if ( key ) {
const existing = propertyMetadata.firstWhere('property', '=', property)
if ( existing ) {
existing.key = key
} else {
propertyMetadata.push({ property, key })
propertyMetadata.push({
property,
key,
debug,
})
}
}
if ( debug ) {
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
}
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
}
}
@@ -118,7 +129,7 @@ export const InjectParam = (key: DependencyKey): ParameterDecorator => {
meta.push({
paramIndex,
key,
overridden: true
overridden: true,
})
}
@@ -135,17 +146,29 @@ export const Singleton = (name?: string): ClassDecorator => {
if ( isInstantiable(target) ) {
const injectionType: InjectionType = {
type: name ? 'named' : 'singleton',
...(name ? { name } : {})
...(name ? { name } : {}),
}
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
Injectable()(target)
if ( name ) {
Container.getContainer().registerNamed(name, target)
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
} else {
Container.getContainer().register(target)
ContainerBlueprint.getContainerBlueprint().register(target)
}
}
}
}
/**
* Register a factory class directly with any created containers.
* @constructor
*/
export const FactoryProducer = (): ClassDecorator => {
return (target) => {
if ( isInstantiable(target) ) {
ContainerBlueprint.getContainerBlueprint().registerFactory(target)
}
}
}

View File

@@ -1,4 +1,4 @@
import {DependencyKey} from "../types";
import {DependencyKey} from '../types'
/**
* Error thrown when a factory is registered with a duplicate dependency key.

View File

@@ -1,4 +1,4 @@
import {DependencyKey} from "../types";
import {DependencyKey} from '../types'
/**
* Error thrown when a dependency key that has not been registered is passed to a resolver.

View File

@@ -1,11 +1,11 @@
import {DependencyRequirement, PropertyDependency} from "../types";
import { Collection } from "../../util";
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
import { Collection } from '../../util'
/**
* Abstract base class for dependency container factories.
* @abstract
*/
export abstract class AbstractFactory {
export abstract class AbstractFactory<T> {
protected constructor(
/**
* Token that was registered for this factory. In most cases, this is the static
@@ -13,7 +13,7 @@ export abstract class AbstractFactory {
* @var
* @protected
*/
protected token: any
protected token: DependencyKey,
) {}
/**
@@ -21,14 +21,14 @@ export abstract class AbstractFactory {
* @param {Array} dependencies - the resolved dependencies, in order
* @param {Array} parameters - the bound constructor parameters, in order
*/
abstract produce(dependencies: any[], parameters: any[]): any
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: any): boolean
abstract match(something: unknown): boolean
/**
* Get the dependency requirements required by this factory's token.

View File

@@ -1,6 +1,6 @@
import {AbstractFactory} from "./AbstractFactory";
import {DependencyRequirement, PropertyDependency, StaticClass} from "../types";
import {Collection} from "../../util";
import {AbstractFactory} from './AbstractFactory'
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
import {Collection} from '../../util'
/**
* A factory whose token is produced by calling a function.
@@ -17,19 +17,19 @@ import {Collection} from "../../util";
* fact.produce([], []) // => 4
* ```
*/
export class ClosureFactory extends AbstractFactory {
export class ClosureFactory<T> extends AbstractFactory<T> {
constructor(
protected readonly name: string | StaticClass<any, any>,
protected readonly token: () => any,
protected readonly name: DependencyKey,
protected readonly token: () => T,
) {
super(token)
}
produce(dependencies: any[], parameters: any[]): any {
produce(): any {
return this.token()
}
match(something: any) {
match(something: unknown): boolean {
return something === this.name
}

View File

@@ -1,12 +1,12 @@
import {AbstractFactory} from "./AbstractFactory";
import {AbstractFactory} from './AbstractFactory'
import {
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
DependencyRequirement,
Instantiable,
PropertyDependency
} from "../types";
import {Collection} from "../../util";
PropertyDependency,
} from '../types'
import {Collection} from '../../util'
import 'reflect-metadata'
/**
@@ -29,9 +29,9 @@ import 'reflect-metadata'
* fact.produce([myServiceInstance], []) // => A { myService: myServiceInstance }
* ```
*/
export class Factory extends AbstractFactory {
export class Factory<T> extends AbstractFactory<T> {
constructor(
protected readonly token: Instantiable<any>
protected readonly token: Instantiable<T>,
) {
super(token)
}
@@ -40,13 +40,15 @@ export class Factory extends AbstractFactory {
return new this.token(...dependencies, ...parameters)
}
match(something: any) {
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
if ( meta ) {
return meta
}
return new Collection<DependencyRequirement>()
}
@@ -56,7 +58,9 @@ export class Factory extends AbstractFactory {
do {
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
if ( loadedMeta ) meta.concat(loadedMeta)
if ( loadedMeta ) {
meta.concat(loadedMeta)
}
currentToken = Object.getPrototypeOf(currentToken)
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)

View File

@@ -1,12 +1,12 @@
import {Factory} from "./Factory";
import {Instantiable} from "../types";
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 extends Factory {
export default class NamedFactory<T> extends Factory<T> {
constructor(
/**
* The name identifying this factory in the container.
@@ -18,12 +18,12 @@ export default class NamedFactory extends Factory {
* The token to be instantiated.
* @type {Instantiable}
*/
protected token: Instantiable<any>,
protected token: Instantiable<T>,
) {
super(token)
}
match(something: any) {
match(something: unknown): boolean {
return something === this.name
}
}

View File

@@ -1,6 +1,6 @@
import { Factory } from './Factory'
import { Collection } from '../../util'
import {DependencyRequirement, PropertyDependency} from "../types";
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
/**
* Container factory which returns its token as its value, without attempting
@@ -19,29 +19,23 @@ import {DependencyRequirement, PropertyDependency} from "../types";
*
* @extends Factory
*/
export default class SingletonFactory extends Factory {
export default class SingletonFactory<T> extends Factory<T> {
constructor(
/**
* Instantiated value of this factory.
* @type FunctionConstructor
* Token identifying this singleton.
*/
protected token: FunctionConstructor,
protected token: DependencyKey,
/**
* String name of this singleton identifying it in the container.
* @type string
* The value of this singleton.
*/
protected key: string,
protected value: T,
) {
super(token)
}
produce(dependencies: any[], parameters: any[]) {
return this.token
}
match(something: any) {
return something === this.key
produce(): T {
return this.value
}
getDependencyKeys(): Collection<DependencyRequirement> {

View File

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

View File

@@ -1,6 +1,6 @@
export const DEPENDENCY_KEYS_METADATA_KEY = 'extollo:di:dependencies:ctor';
export const DEPENDENCY_KEYS_PROPERTY_METADATA_KEY = 'extollo:di:dependencies:properties';
export const DEPENDENCY_KEYS_SERVICE_TYPE_KEY = 'extollo:di:service_type';
export const DEPENDENCY_KEYS_METADATA_KEY = 'extollo:di:dependencies:ctor'
export const DEPENDENCY_KEYS_PROPERTY_METADATA_KEY = 'extollo:di:dependencies:properties'
export const DEPENDENCY_KEYS_SERVICE_TYPE_KEY = 'extollo:di:service_type'
/**
* Interface that designates a particular value as able to be constructed.
@@ -13,20 +13,36 @@ export interface Instantiable<T> {
* Returns true if the given value is instantiable.
* @param what
*/
export function isInstantiable<T>(what: any): what is Instantiable<T> {
return (typeof what === 'object' || typeof what === 'function') && 'constructor' in what && typeof what.constructor === 'function'
export function isInstantiable<T>(what: unknown): what is Instantiable<T> {
return (
Boolean(what)
&& (typeof what === 'object' || typeof what === 'function')
&& (what !== null)
&& 'constructor' in what
&& typeof what.constructor === 'function'
)
}
/**
* Returns true if the given value is instantiable and, once instantiated,
* will create an instance of the given static class.
* @param what
* @param type
*/
export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): what is Instantiable<T> {
return isInstantiable(what) && what.prototype instanceof type
}
/**
* Type that identifies a value as a static class, even if it is not instantiable.
*/
export type StaticClass<T, T2> = Function & {prototype: T} & T2
export type StaticClass<T, T2> = Function & {prototype: T} & T2 // eslint-disable-line @typescript-eslint/ban-types
/**
* Returns true if the parameter is a static class.
* @param something
*/
export function isStaticClass<T, T2>(something: any): something is StaticClass<T, T2> {
export function isStaticClass<T, T2>(something: unknown): something is StaticClass<T, T2> {
return typeof something === 'function' && typeof something.prototype !== 'undefined'
}
@@ -35,6 +51,11 @@ export function isStaticClass<T, T2>(something: any): something is StaticClass<T
*/
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
/**
* A DependencyKey, but typed
*/
export type TypedDependencyKey<T> = Instantiable<T> | StaticClass<T, any> | string
/**
* Interface used to store dependency requirements by their place in the injectable
* target's parameters.
@@ -52,6 +73,7 @@ export interface DependencyRequirement {
export interface PropertyDependency {
key: DependencyKey,
property: string | symbol,
debug?: boolean,
}
/**

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