1 Commits
0.5.0 ... 0.1.5

Author SHA1 Message Date
226cb0193b release(0.1.5)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-04-10 05:10:26 -05:00
274 changed files with 844 additions and 25184 deletions

View File

@@ -1,86 +1,3 @@
kind: pipeline
type: docker
name: docs
steps:
# ============ BUILD STEPS ===============
- name: build documentation
image: glmdev/node-pnpm:latest
commands:
- pnpm i --silent
- pnpm docs:build
- cd docs && tar czf ../extollo_api_documentation.tar.gz www
# =============== DEPLOY STEPS ===============
- name: copy artifacts to static host
image: appleboy/drone-scp
settings:
host:
from_secret: docs_deploy_host
username:
from_secret: docs_deploy_user
key:
from_secret: docs_deploy_key
port: 22
source: extollo_api_documentation.tar.gz
target: /var/nfs/general/static/sites/extollo
when:
event: promote
target: docs
- name: deploy artifacts on static host
image: appleboy/drone-ssh
settings:
host:
from_secret: docs_deploy_host
username:
from_secret: docs_deploy_user
key:
from_secret: docs_deploy_key
port: 22
script:
- cd /var/nfs/general/static/sites/extollo
- rm -rf docs
- tar xzf extollo_api_documentation.tar.gz
- rm -rf extollo_api_documentation.tar.gz
- mv www docs
when:
event: promote
target: docs
# =============== BUILD NOTIFICATIONS ===============
- name: send build success notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
"message": "Build & deploy completed successfully.",
"priority": 4
}
when:
status: success
event:
- promote
- name: send build error notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
"message": "Documentation build failed!",
"priority": 6
}
when:
status: failure
---
kind: pipeline kind: pipeline
name: default name: default
type: docker type: docker
@@ -217,3 +134,18 @@ steps:
when: when:
status: failure status: failure
event: pull_request 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

View File

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

View File

@@ -1,113 +0,0 @@
{
"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"
}
}

6
.idea/vcs.xml generated
View File

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

1
docs/.gitignore vendored
View File

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

View File

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

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

View File

@@ -1,7 +0,0 @@
# 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))

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -1,19 +0,0 @@
/* 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.

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

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

Before

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 691 B

View File

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,51 +0,0 @@
<!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>

View File

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

View File

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

@@ -1,3 +0,0 @@
<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", "name": "@extollo/lib",
"version": "0.5.0", "version": "0.1.5",
"description": "The framework library that lifts up your code.", "description": "The framework library that lifts up your code.",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
@@ -8,51 +8,25 @@
"lib": "lib" "lib": "lib"
}, },
"dependencies": { "dependencies": {
"@atao60/fse-cli": "^0.1.6", "@extollo/di": "^0.4.5",
"@types/bcrypt": "^5.0.0", "@extollo/util": "^0.3.3",
"@types/busboy": "^0.2.3", "@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1", "@types/negotiator": "^0.6.1",
"@types/node": "^14.17.4", "@types/node": "^14.14.37",
"@types/pg": "^8.6.0",
"@types/pluralize": "^0.0.29",
"@types/pug": "^2.0.4", "@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", "busboy": "^0.3.1",
"cli-table": "^0.3.6",
"colors": "^1.4.0", "colors": "^1.4.0",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"mime-types": "^2.1.31",
"mkdirp": "^1.0.4",
"negotiator": "^0.6.2", "negotiator": "^0.6.2",
"pg": "^8.6.0",
"pluralize": "^8.0.0",
"pug": "^3.0.2", "pug": "^3.0.2",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"ssh2": "^1.1.0",
"ts-node": "^9.1.1", "ts-node": "^9.1.1",
"typedoc": "^0.20.36", "typescript": "^4.2.3"
"typedoc-plugin-pages-fork": "^0.0.1",
"typedoc-plugin-sourcefile-url": "^1.0.6",
"typescript": "^4.2.3",
"uuid": "^8.3.2"
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint && rimraf lib",
"build": "tsc", "build": "tsc",
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js", "app": "tsc && node lib/index.js",
"prepare": "pnpm run build", "prepare": "pnpm run build"
"docs:build": "typedoc --options typedoc.json",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint --fix . --ext .ts"
}, },
"files": [ "files": [
"lib/**/*" "lib/**/*"
@@ -64,16 +38,5 @@
"url": "https://code.garrettmills.dev/extollo/lib" "url": "https://code.garrettmills.dev/extollo/lib"
}, },
"author": "garrettmills <shout@garrettmills.dev>", "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
}
}
} }

View File

@@ -1,17 +0,0 @@
{
"groups": [
{
"title": "Documentation",
"pages": [
{
"title": "Getting Started",
"source": "./docs/pages/Getting-Started.md"
},
{
"title": "About Extollo",
"source": "./docs/pages/About-Extollo.md"
}
]
}
]
}

2103
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
import {Instantiable} from '../di'
import {ORMUserRepository} from './orm/ORMUserRepository'
/**
* 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,
}
}

View File

@@ -1,31 +0,0 @@
import {SecurityContext} from '../SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableRepository} from '../types'
/**
* Security context implementation that uses the session as storage.
*/
@Injectable()
export class SessionSecurityContext extends SecurityContext {
@Inject()
protected readonly session!: Session
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'session')
}
getCredentials(): Awaitable<Record<string, string>> {
return {
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
}
}
persist(): Awaitable<void> {
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
}
}

View File

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

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

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

@@ -1,23 +0,0 @@
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'

View File

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

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

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

View File

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

@@ -1,41 +0,0 @@
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
import {Awaitable, Maybe} from '../../util'
import {ORMUser} from './ORMUser'
import {Injectable} from '../../di'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
.where('username', '=', id)
.first()
}
/**
* Try to look up a user by the credentials provided.
* If a securityIdentifier is specified, look up the user by username.
* If username/password are specified, look up the user and verify the password.
* @param credentials
*/
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
if ( credentials.securityIdentifier ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.securityIdentifier)
.first()
}
if ( credentials.username && credentials.password ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.username)
.first()
if ( user && await user.verifyPassword(credentials.password) ) {
return user
}
}
}
}

View File

@@ -1,36 +0,0 @@
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
/**
* Base class for entities that can be authenticated.
*/
export abstract class Authenticatable implements Rehydratable {
/** Get the 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: Record<string, string>): Awaitable<Maybe<Authenticatable>>
}

View File

@@ -1,466 +0,0 @@
import {Injectable, Inject} from '../di'
import {infer, ErrorWithContext} from '../util'
import {CLIOption} from './directive/options/CLIOption'
import {PositionalOption} from './directive/options/PositionalOption'
import {FlagOption} from './directive/options/FlagOption'
import {AppClass} from '../lifecycle/AppClass'
import {Logging} from '../service/Logging'
/**
* Type alias for a definition of a command-line option.
*
* This can be either an instance of CLIOption or a string describing an option.
*
* @example
* Some examples of positional/flag options defined by strings:
* `'{file name} | canonical name of the resource to create'`
*
* `'--push -p {value} | the value to be pushed'`
*
* `'--force -f | do a force push'`
*/
export type OptionDefinition = CLIOption<any> | string
/**
* An error thrown when an invalid option was detected.
*/
export class OptionValidationError extends ErrorWithContext {}
/**
* A base class representing a sub-command in the command-line utility.
*/
@Injectable()
export abstract class Directive extends AppClass {
@Inject()
protected readonly logging!: Logging
/** Parsed option values. */
private optionValues: any
/**
* Get the keyword or array of keywords that will specify this directive.
*
* @example
* If this returns `['up', 'start']`, the directive can be run by either of:
*
* ```shell
* ./ex up
* ./ex start
* ```
*/
public abstract getKeywords(): string | string[]
/**
* Get the usage description of this directive. Should be brief (1 sentence).
*/
public abstract getDescription(): string
/**
* Optionally, specify a longer usage text that is shown on the directive's `--help` page.
*/
public getHelpText(): string {
return ''
}
/**
* Get an array of options defined for this command.
*/
public getOptions(): OptionDefinition[] {
return []
}
/**
* Called when the directive is run from the command line.
*
* The raw arguments are provided as `argv`, but you are encouraged to use
* `getOptions()` and `option()` helpers to access the parsed options instead.
*
* @param argv
*/
public abstract handle(argv: string[]): void | Promise<void>
/**
* Sets the parsed option values.
* @param optionValues
* @private
*/
private setOptionValues(optionValues: any) {
this.optionValues = optionValues
}
/**
* Get the value of a parsed option. If none exists, return `defaultValue`.
* @param name
* @param defaultValue
*/
public option(name: string, defaultValue?: unknown): any {
if ( name in this.optionValues ) {
return this.optionValues[name]
}
return defaultValue
}
/**
* Invoke this directive with the specified arguments.
*
* If usage was requested (see `didRequestUsage()`), it prints the extended usage info.
*
* Otherwise, it parses the options from `argv` and calls `handle()`.
*
* @param argv
*/
async invoke(argv: string[]): Promise<void> {
const options = this.getResolvedOptions()
if ( this.didRequestUsage(argv) ) {
const positionalArguments: PositionalOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof PositionalOption ) {
positionalArguments.push(opt)
}
})
const flagArguments: FlagOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof FlagOption ) {
flagArguments.push(opt)
}
})
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
this.nativeOutput([
'',
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
'',
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
].join('\n'))
if ( positionalArguments.length ) {
this.nativeOutput([
'',
`POSITIONAL ARGUMENTS:`,
...(positionalArguments.map(arg => {
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
})),
].join('\n'))
}
if ( flagArguments.length ) {
this.nativeOutput([
'',
`FLAGS:`,
...(flagArguments.map(arg => {
return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}`
})),
].join('\n'))
}
const help = this.getHelpText()
if ( help ) {
this.nativeOutput('\n' + help)
}
this.nativeOutput('\n')
} else {
try {
const optionValues = this.parseOptions(options, argv)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e) {
this.nativeOutput(e.message)
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
this.nativeOutput(` - Expecting: ${e.context.expecting}`)
}
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
for ( const req of e.context.requirements ) {
this.nativeOutput(` - ${req}`)
}
}
if ( e.context.value ) {
this.nativeOutput(` - ${e.context.value}`)
}
}
this.nativeOutput('\nUse --help for more info.')
}
}
}
/**
* Resolve the array of option definitions to CLIOption instances.
* Of note, this resolves the string-form definitions to actual CLIOption instances.
*/
public getResolvedOptions(): CLIOption<any>[] {
return this.getOptions().map(option => {
if ( typeof option === 'string' ) {
return this.instantiateOptionFromString(option)
} else {
return option
}
})
}
/**
* Get the main keyword displayed for this directive.
* @example
* If `getKeywords()` returns `['up', 'start']`, this will return `'up'`.
*/
public getMainKeyword(): string {
const kws = this.getKeywords()
if ( Array.isArray(kws) ) {
return kws[0]
}
return kws
}
/**
* Returns true if the given keyword should invoke this directive.
* @param name
*/
public matchesKeyword(name: string): boolean {
let kws = this.getKeywords()
if ( !Array.isArray(kws) ) {
kws = [kws]
}
return kws.includes(name)
}
/**
* Print the given output to the log as success text.
* @param output
*/
success(output: unknown): void {
this.logging.success(output, true)
}
/**
* Print the given output to the log as error text.
* @param output
*/
error(output: unknown): void {
this.logging.error(output, true)
}
/**
* Print the given output to the log as warning text.
* @param output
*/
warn(output: unknown): void {
this.logging.warn(output, true)
}
/**
* Print the given output to the log as info text.
* @param output
*/
info(output: unknown): void {
this.logging.info(output, true)
}
/**
* Print the given output to the log as debugging text.
* @param output
*/
debug(output: unknown): void {
this.logging.debug(output, true)
}
/**
* Print the given output to the log as verbose text.
* @param output
*/
verbose(output: unknown): void {
this.logging.verbose(output, true)
}
/**
* Get the flag option that signals help. Usually, this is named 'help'
* and supports the flags '--help' and '-?'.
*/
getHelpOption(): FlagOption<any> {
return new FlagOption('--help', '-?', 'usage information about this directive')
}
/**
* Process the raw CLI arguments using an array of option class instances to build
* a mapping of option names to provided values.
*/
parseOptions(options: CLIOption<any>[], args: string[]): {[key: string]: any} {
let positionalArguments: PositionalOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof PositionalOption ) {
positionalArguments.push(opt)
}
})
const flagArguments: FlagOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof FlagOption ) {
flagArguments.push(opt)
}
})
const optionValue: any = {}
flagArguments.push(this.getHelpOption())
let expectingFlagArgument = false
let positionalFlagName = ''
for ( const value of args ) {
if ( value.startsWith('--') ) {
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, {
expecting: positionalFlagName,
})
} else {
const flagArgument = flagArguments.filter(x => x.longFlag === value)
if ( flagArgument.length < 1 ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
value,
})
} else {
if ( flagArgument[0].argumentDescription ) {
positionalFlagName = flagArgument[0].getArgumentName()
expectingFlagArgument = true
} else {
optionValue[flagArgument[0].getArgumentName()] = true
}
}
}
} else if ( value.startsWith('-') ) {
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
expecting: positionalFlagName,
})
} else {
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
if ( flagArgument.length < 1 ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
value,
})
} else {
if ( flagArgument[0].argumentDescription ) {
positionalFlagName = flagArgument[0].getArgumentName()
expectingFlagArgument = true
} else {
optionValue[flagArgument[0].getArgumentName()] = true
}
}
}
} else if ( expectingFlagArgument ) {
const inferredValue = infer(value)
const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0]
if ( !optionInstance.validate(inferredValue) ) {
throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, {
requirements: optionInstance.getRequirementDisplays(),
})
}
optionValue[positionalFlagName] = inferredValue
expectingFlagArgument = false
} else {
if ( positionalArguments.length < 1 ) {
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
value,
})
} else {
const inferredValue = infer(value)
if ( !positionalArguments[0].validate(inferredValue) ) {
throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, {
requirements: positionalArguments[0].getRequirementDisplays(),
})
}
optionValue[positionalArguments[0].getArgumentName()] = infer(value)
positionalArguments = positionalArguments.slice(1)
}
}
}
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
expecting: positionalFlagName,
})
}
if ( positionalArguments.length > 0 ) {
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
expecting: positionalArguments[0].getArgumentName(),
})
}
return optionValue
}
/**
* Create an instance of CLIOption based on a string definition of a particular format.
*
* e.g. '{file name} | canonical name of the resource to create'
* e.g. '--push -p {value} | the value to be pushed'
* e.g. '--force -f | do a force push'
*
* @param string
*/
protected instantiateOptionFromString(string: string): CLIOption<any> {
if ( string.startsWith('{') ) {
// The string is a positional argument
const stringParts = string.split('|').map(x => x.trim())
const name = stringParts[0].replace(/\{|\}/g, '')
return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name))
} else {
// The string is a flag argument
const stringParts = string.split('|').map(x => x.trim())
// Parse the flag parts first
const hasArgument = stringParts[0].indexOf('{') >= 0
const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim()
const flagParts = flagString.split(' ')
let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined
if ( !longFlag && flagParts.length > 1 ) {
if ( flagParts[1].startsWith('--') ) {
longFlag = flagParts[1]
}
}
let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined
if ( !shortFlag && flagParts.length > 1 ) {
if ( flagParts[1].length === 2 ) {
shortFlag = flagParts[1]
}
}
const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined
const description = stringParts.length > 1 ? stringParts[1] : undefined
return new FlagOption(longFlag, shortFlag, description, argumentDescription)
}
}
/**
* Determines if, at any point in the arguments, the help option's short or long flag appears.
* @returns {boolean} - true if the help flag appeared
*/
didRequestUsage(argv: string[]): boolean {
const helpOption = this.getHelpOption()
for ( const arg of argv ) {
if ( arg.trim() === helpOption.longFlag || arg.trim() === helpOption.shortFlag ) {
return true
}
}
return false
}
protected nativeOutput(...outputs: any[]): void {
console.log(...outputs) // eslint-disable-line no-console
}
}

View File

@@ -1,64 +0,0 @@
import {UniversalPath} from '../util'
/**
* Interface defining a template that can be generated using the TemplateDirective.
*/
export interface Template {
/**
* The name of the template as it will be specified from the command line.
*
* @example
* If this is `'mytemplate'`, then the template will be created with:
*
* ```shell
* ./ex new mytemplate some:path
* ```
*/
name: string,
/**
* The suffix of the file generated by this template.
* @example `.mytemplate.ts`
* @example `.controller.ts`
*/
fileSuffix: string,
/**
* Brief description of the template displayed on the --help page for the TemplateDirective.
* Should be brief (1 sentence).
*/
description: string,
/**
* Array of path-strings that are resolved relative to the base `app` directory.
* @example `['http', 'controllers']`
* @example `['units']`
*/
baseAppPath: string[],
/**
* Render the given template to a string which will be written to the file.
* Note: this method should NOT write the contents to `targetFilePath`.
*
* @example
* If the user enters:
*
* ```shell
* ./ex new mytemplate path:to:NewInstance
* ```
*
* Then, the following params are:
* ```typescript
* {
* name: 'NewInstance',
* fullCanonicalPath: 'path:to:NewInstance',
* targetFilePath: UniversalPath { }
* }
* ```
*
* @param name - the singular name of the resource
* @param fullCanonicalName - the full canonical name of the resource
* @param targetFilePath - the UniversalPath where the file will be written
*/
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
}

View File

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

@@ -1,33 +0,0 @@
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,31 +0,0 @@
import {Directive} from '../Directive'
import {CommandLineApplication} from '../service'
import {Injectable} from '../../di'
import {ErrorWithContext} from '../../util'
import {Unit} from '../../lifecycle/Unit'
/**
* A directive that starts the framework's final target normally.
* In most cases, this runs the HTTP server, which would have been replaced
* by the CommandLineApplication unit.
*/
@Injectable()
export class RunDirective extends Directive {
getDescription(): string {
return 'run the application normally'
}
getKeywords(): string | string[] {
return ['run', 'up']
}
async handle(): Promise<void> {
if ( !CommandLineApplication.getReplacement() ) {
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
}
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
await this.app().startUnit(unit)
await this.app().stopUnit(unit)
}
}

View File

@@ -1,48 +0,0 @@
import {Directive} from '../Directive'
import * as colors from 'colors/safe'
import * as repl from 'repl'
import {DependencyKey} from '../../di'
/**
* Launch an interactive REPL shell from within the application.
* This is very useful for debugging and testing things during development.
*/
export class ShellDirective extends Directive {
protected options: any = {
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
}
/**
* The created Node.js REPL server.
* @protected
*/
protected repl?: repl.REPLServer
getDescription(): string {
return 'launch an interactive shell inside your application'
}
getKeywords(): string | string[] {
return ['shell']
}
getHelpText(): string {
return ''
}
async handle(): Promise<void> {
const state: any = {
app: this.app(),
lib: await import('../../index'),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
}
await new Promise<void>(res => {
this.nativeOutput(this.options.welcome)
this.repl = repl.start(this.options.prompt)
Object.assign(this.repl.context, state)
this.repl.on('exit', () => res())
})
}
}

View File

@@ -1,90 +0,0 @@
import {Directive, OptionDefinition} from '../Directive'
import {PositionalOption} from './options/PositionalOption'
import {CommandLine} from '../service'
import {Inject, Injectable} from '../../di'
import {ErrorWithContext} from '../../util'
/**
* Create a new file based on a template registered with the CommandLine service.
*/
@Injectable()
export class TemplateDirective extends Directive {
@Inject()
protected readonly cli!: CommandLine
getKeywords(): string | string[] {
return ['new', 'make']
}
getDescription(): string {
return 'create a new file from a registered template'
}
getOptions(): OptionDefinition[] {
const registeredTemplates = this.cli.getTemplates()
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
template.whitelist(...registeredTemplates.pluck('name').all())
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
return [template, destination]
}
getHelpText(): string {
const registeredTemplates = this.cli.getTemplates()
return [
'Modules in Extollo register templates that can be used to quickly create common file types.',
'',
'For example, you can create a new model from @extollo/orm using the "model" template:',
'',
'./ex new model auth:Group',
'',
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
'',
'AVAILABLE TEMPLATES:',
'',
...(registeredTemplates.map(template => {
return ` - ${template.name}: ${template.description}`
}).all()),
].join('\n')
}
async handle(): Promise<void> {
const templateName: string = this.option('template_name')
const destinationName: string = this.option('file_name')
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
this.error(`The destination should be a canonical name, not a file path.`)
this.error(`Reference sub-directories using the : character instead.`)
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
process.exitCode = 1
return
}
const template = this.cli.getTemplate(templateName)
if ( !template ) {
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
templateName,
destinationName,
})
}
const name = destinationName.split(':').reverse()[0]
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
if ( await path.exists() ) {
this.error(`File already exists: ${path}`)
process.exitCode = 1
return
}
// Make sure the parent direction exists
await path.concat('..').mkdir()
const contents = await template.render(name, destinationName, path.clone())
await path.write(contents)
this.success(`Created new ${template.name} in ${path}`)
}
}

View File

@@ -1,54 +0,0 @@
import {Directive} from '../Directive'
import {Injectable, Inject} from '../../di'
import {padRight} from '../../util'
import {CommandLine} from '../service'
/**
* Directive that prints the help message and usage information about
* directives registered with the command line utility.
*/
@Injectable()
export class UsageDirective extends Directive {
@Inject()
protected readonly cli!: CommandLine
public getKeywords(): string | string[] {
return 'help'
}
public getDescription(): string {
return 'print information about available commands'
}
public handle(): void | Promise<void> {
const directiveStrings = this.cli.getDirectives()
.map(cls => this.make<Directive>(cls))
.map<[string, string]>(dir => {
return [dir.getMainKeyword(), dir.getDescription()]
})
const maxLen = directiveStrings.max<number>(x => x[0].length)
const printStrings = directiveStrings.map(grp => {
return [padRight(grp[0], maxLen + 1), grp[1]]
})
.map(grp => {
return ` ${grp[0]}: ${grp[1]}`
})
.toArray()
this.nativeOutput(this.cli.getASCIILogo())
this.nativeOutput([
'',
'Welcome to Extollo! Specify a command to get started.',
'',
`USAGE: ex <directive> [..options]`,
'',
...printStrings,
'',
'For usage information about a particular command, pass the --help flag.',
'-------------------------------------------',
`powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills`,
].join('\n'))
}
}

View File

@@ -1,251 +0,0 @@
/**
* A CLI option. Supports basic comparative, and set-based validation.
* @class
*/
export abstract class CLIOption<T> {
/**
* Do we use the whitelist?
* @type {boolean}
* @private
*/
protected useWhitelist = false
/**
* Do we use the blacklist?
* @type {boolean}
* @private
*/
protected useBlacklist = false
/**
* Do we use the less-than comparison?
* @type {boolean}
* @private
*/
protected useLessThan = false
/**
* Do we use the greater-than comparison?
* @type {boolean}
* @private
*/
protected useGreaterThan = false
/**
* Do we use the equality operator?
* @type {boolean}
* @private
*/
protected useEquality = false
/**
* Is this option optional?
* @type {boolean}
* @private
*/
protected isOptional = false
/**
* Whitelisted values.
* @type {Array<*>}
* @private
*/
protected whitelistItems: T[] = []
/**
* Blacklisted values.
* @type {Array<*>}
* @private
*/
protected blacklistItems: T[] = []
/**
* Value to be compared in less than.
* @type {*}
* @private
*/
protected lessThanValue?: T
/**
* If true, the less than will be less than or equal to.
* @type {boolean}
* @private
*/
protected lessThanBit = false
/**
* Value to be compared in greater than.
* @type {*}
* @private
*/
protected greaterThanValue?: T
/**
* If true, the greater than will be greater than or equal to.
* @type {boolean}
* @private
*/
protected greaterThanBit = false
/**
* The value to be used to check equality.
* @type {*}
* @private
*/
protected equalityValue?: T
/**
* Whitelist the specified item or items and enable the whitelist.
* @param {...*} items - the items to whitelist
*/
whitelist(...items: T[]): this {
this.useWhitelist = true
items.forEach(item => this.whitelistItems.push(item))
return this
}
/**
* Blacklist the specified item or items and enable the blacklist.
* @param {...*} items - the items to blacklist
*/
blacklist(...items: T[]): this {
this.useBlacklist = true
items.forEach(item => this.blacklistItems.push(item))
return this
}
/**
* Specifies the value to be used in less-than comparison and enables less-than comparison.
* @param {*} value
*/
lessThan(value: T): this {
this.useLessThan = true
this.lessThanValue = value
return this
}
/**
* Specifies the value to be used in less-than or equal-to comparison and enables that comparison.
* @param {*} value
*/
lessThanOrEqualTo(value: T): this {
this.lessThanBit = true
this.lessThan(value)
return this
}
/**
* Specifies the value to be used in greater-than comparison and enables that comparison.
* @param {*} value
*/
greaterThan(value: T): this {
this.useGreaterThan = true
this.greaterThanValue = value
return this
}
/**
* Specifies the value to be used in greater-than or equal-to comparison and enables that comparison.
* @param {*} value
*/
greaterThanOrEqualTo(value: T): this {
this.greaterThanBit = true
this.greaterThan(value)
return this
}
/**
* Specifies the value to be used in equality comparison and enables that comparison.
* @param {*} value
*/
equals(value: T): this {
this.useEquality = true
this.equalityValue = value
return this
}
/**
* Checks if the specified value passes the configured comparisons.
* @param value
* @returns {boolean}
*/
validate(value: T): boolean {
let isValid = true
if ( this.useEquality ) {
isValid = isValid && (this.equalityValue === value)
}
if ( this.useLessThan && typeof this.lessThanValue !== 'undefined' ) {
if ( this.lessThanBit ) {
isValid = isValid && (value <= this.lessThanValue)
} else {
isValid = isValid && (value < this.lessThanValue)
}
}
if ( this.useGreaterThan && typeof this.greaterThanValue !== 'undefined' ) {
if ( this.greaterThanBit ) {
isValid = isValid && (value >= this.greaterThanValue)
} else {
isValid = isValid && (value > this.greaterThanValue)
}
}
if ( this.useWhitelist ) {
isValid = isValid && this.whitelistItems.some(x => {
return x === value
})
}
if ( this.useBlacklist ) {
isValid = isValid && !(this.blacklistItems.some(x => x === value))
}
return isValid
}
/**
* Sets the Option as optional.
*/
optional(): this {
this.isOptional = true
return this
}
/**
* Get the argument name. Should be overridden by child classes.
* @returns {string}
*/
abstract getArgumentName(): string
/**
* Get an array of strings denoting the human-readable requirements for this option to be valid.
* @returns {Array<string>}
*/
getRequirementDisplays(): string[] {
const clauses = []
if ( this.useBlacklist ) {
clauses.push(`must not be one of: ${this.blacklistItems.map(x => String(x)).join(', ')}`)
}
if ( this.useWhitelist ) {
clauses.push(`must be one of: ${this.whitelistItems.map(x => String(x)).join(', ')}`)
}
if ( this.useGreaterThan ) {
clauses.push(`must be greater than${this.greaterThanBit ? ' or equal to' : ''}: ${String(this.greaterThanValue)}`)
}
if ( this.useLessThan ) {
clauses.push(`must be less than${this.lessThanBit ? ' or equal to' : ''}: ${String(this.lessThanValue)}`)
}
if ( this.useEquality ) {
clauses.push(`must be equal to: ${String(this.equalityValue)}`)
}
return clauses
}
}

View File

@@ -1,47 +0,0 @@
import {CLIOption} from './CLIOption'
/**
* Non-positional, flag-based CLI option.
*/
export class FlagOption<T> extends CLIOption<T> {
constructor(
/**
* The long-form flag for this option.
* @example --path, --create
*/
public readonly longFlag?: string,
/**
* The short-form flag for this option.
* @example -p, -c
*/
public readonly shortFlag?: string,
/**
* Usage message describing this flag.
*/
public readonly message?: string,
/**
* Description of the argument required by this flag.
* If this is set, the flag will expect a positional argument to follow as a param.
*/
public readonly argumentDescription?: string,
) {
super()
}
/**
* Get the referential name for this option.
* Defaults to the long flag (without the '--'). If this cannot
* be found, the short flag (without the '-') is used.
* @returns {string}
*/
getArgumentName(): string {
if ( this.longFlag ) {
return this.longFlag.replace('--', '')
} else if ( this.shortFlag ) {
return this.shortFlag.replace('-', '')
}
throw new Error('Missing either a long- or short-flag for FlagOption.')
}
}

View File

@@ -1,34 +0,0 @@
import {CLIOption} from './CLIOption'
/**
* A positional CLI option. Defined without a flag.
*/
export class PositionalOption<T> extends CLIOption<T> {
/**
* Instantiate the option.
* @param {string} name - the name of the option
* @param {string} message - message describing the option
*/
constructor(
/**
* The display name of this positional argument.
* @example path, filename
*/
public readonly name: string,
/**
* A usage message describing this parameter.
*/
public readonly message: string = '',
) {
super()
}
/**
* Gets the name of the option.
* @returns {string}
*/
getArgumentName(): string {
return this.name
}
}

View File

@@ -1,13 +0,0 @@
export * from './Directive'
export * from './Template'
export * from './service/CommandLineApplication'
export * from './service/CommandLine'
export * from './directive/options/CLIOption'
export * from './directive/options/FlagOption'
export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective'
export * from './directive/TemplateDirective'
export * from './directive/UsageDirective'

View File

@@ -1,130 +0,0 @@
import {Singleton, Instantiable, Inject} from '../../di'
import {Collection} from '../../util'
import {CommandLineApplication} from './CommandLineApplication'
import {Directive} from '../Directive'
import {Template} from '../Template'
import {templateDirective} from '../templates/directive'
import {templateUnit} from '../templates/unit'
import {templateController} from '../templates/controller'
import {templateMiddleware} from '../templates/middleware'
import {templateRoutes} from '../templates/routes'
import {templateConfig} from '../templates/config'
import {Unit} from '../../lifecycle/Unit'
import {Logging} from '../../service/Logging'
/**
* Service for managing directives, templates, and other resources related
* to the command line utilities.
*/
@Singleton()
export class CommandLine extends Unit {
@Inject()
protected readonly logging!: Logging
/** Directive classes registered with the CLI command. */
protected directives: Collection<Instantiable<Directive>> = new Collection<Instantiable<Directive>>()
/** Templates registered with the CLI command. These can be created with the TemplateDirective. */
protected templates: Collection<Template> = new Collection<Template>()
constructor() {
super()
}
async up(): Promise<void> {
this.registerTemplate(templateDirective)
this.registerTemplate(templateUnit)
this.registerTemplate(templateController)
this.registerTemplate(templateMiddleware)
this.registerTemplate(templateRoutes)
this.registerTemplate(templateConfig)
}
/**
* Returns true if the application was started from the command line.
*/
public isCLI(): boolean {
return this.app().hasUnit(CommandLineApplication)
}
/**
* Returns a string containing the Extollo ASCII logo.
*/
public getASCIILogo(): string {
return ` _
/ /\\ ______ _ _ _
/ / \\ | ____| | | | | |
/ / /\\ \\ | |__ __ _| |_ ___ | | | ___
/ / /\\ \\ \\ | __| \\ \\/ / __/ _ \\| | |/ _ \\
/ / / \\ \\_\\ | |____ > <| || (_) | | | (_) |
\\/_/ \\/_/ |______/_/\\_\\\\__\\___/|_|_|\\___/
`
}
/**
* Register a Directive class with this service. This will make
* the directive available for use on the CLI.
* @param directiveClass
*/
public registerDirective(directiveClass: Instantiable<Directive>): this {
if ( !this.directives.includes(directiveClass) ) {
this.directives.push(directiveClass)
}
return this
}
/**
* Returns true if the given directive is registered with this service.
* @param directiveClass
*/
public hasDirective(directiveClass: Instantiable<Directive>): boolean {
return this.directives.includes(directiveClass)
}
/**
* Get a collection of all registered directives.
*/
public getDirectives(): Collection<Instantiable<Directive>> {
return this.directives.clone()
}
/**
* Register the given template with this service. This makes the template
* available for use with the TemplateDirective service.
* @param template
*/
public registerTemplate(template: Template): this {
if ( !this.templates.firstWhere('name', '=', template.name) ) {
this.templates.push(template)
} else {
this.logging.warn(`Duplicate template will not be registered: ${template.name}`)
this.logging.debug(`Duplicate template registered at: ${(new Error()).stack}`)
}
return this
}
/**
* Returns true if a template with the given name exists.
* @param name
*/
public hasTemplate(name: string): boolean {
return Boolean(this.templates.firstWhere('name', '=', name))
}
/**
* Returns the template with the given name, if one exists.
* @param name
*/
public getTemplate(name: string): Template | undefined {
return this.templates.firstWhere('name', '=', name)
}
/**
* Get a collection of all registered templates.
*/
public getTemplates(): Collection<Template> {
return this.templates.clone()
}
}

View File

@@ -1,62 +0,0 @@
import {Unit} from '../../lifecycle/Unit'
import {Logging} from '../../service/Logging'
import {Singleton, Inject} from '../../di/decorator/injection'
import {CommandLine} from './CommandLine'
import {UsageDirective} from '../directive/UsageDirective'
import {Directive} from '../Directive'
import {ShellDirective} from '../directive/ShellDirective'
import {TemplateDirective} from '../directive/TemplateDirective'
import {RunDirective} from '../directive/RunDirective'
import {RoutesDirective} from '../directive/RoutesDirective'
import {RouteDirective} from '../directive/RouteDirective'
/**
* Unit that takes the place of the final unit in the application that handles
* invocations from the command line.
*/
@Singleton()
export class CommandLineApplication extends Unit {
/** The unit that was replaced by the CLI app. */
private static replacement?: typeof Unit
/** Set the replaced unit. */
public static setReplacement(unitClass?: typeof Unit): void {
this.replacement = unitClass
}
/** Get the replaced unit. */
public static getReplacement(): typeof Unit | undefined {
return this.replacement
}
@Inject()
protected readonly cli!: CommandLine
@Inject()
protected readonly logging!: Logging
constructor() {
super()
}
public async up(): Promise<void> {
this.cli.registerDirective(UsageDirective)
this.cli.registerDirective(ShellDirective)
this.cli.registerDirective(TemplateDirective)
this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()
.map(dirCls => this.make<Directive>(dirCls))
.firstWhere(dir => dir.matchesKeyword(argv[0]))
if ( match ) {
await match.invoke(argv.slice(1))
} else {
const usage = this.make<UsageDirective>(UsageDirective)
await usage.handle()
}
}
}

View File

@@ -1,2 +0,0 @@
export * from './CommandLine'
export * from './CommandLineApplication'

View File

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

View File

@@ -1,29 +0,0 @@
import {Template} from '../Template'
/**
* Template that generates a new controller in the app/http/controllers directory.
*/
const templateController: Template = {
name: 'controller',
fileSuffix: '.controller.ts',
description: 'Create a controller class that can be used to handle requests.',
baseAppPath: ['http', 'controllers'],
render(name: string) {
return `import {Controller, view, Inject, Injectable} from '@extollo/lib'
/**
* ${name} Controller
* ------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name} extends Controller {
public ${name.toLowerCase()}() {
return view('${name.toLowerCase()}')
}
}
`
},
}
export { templateController }

View File

@@ -1,41 +0,0 @@
import {Template} from '../Template'
/**
* Template that generates a new Directive class in the app/directives directory.
*/
const templateDirective: Template = {
name: 'directive',
fileSuffix: '.directive.ts',
description: 'Create a new Directive class which adds functionality to the ./ex command.',
baseAppPath: ['directives'],
render(name: string) {
return `import {Directive, OptionDefinition, Injectable} from '@extollo/lib'
/**
* ${name} Directive
* ---------------------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name}Directive extends Directive {
getKeywords(): string | string[] {
return ['${name.toLowerCase()}']
}
getDescription(): string {
return ''
}
getOptions(): OptionDefinition[] {
return []
}
async handle(argv: string[]) {
}
}
`
},
}
export { templateDirective }

View File

@@ -1,29 +0,0 @@
import {Template} from '../Template'
/**
* Template that generates a new middleware class in app/http/middlewares.
*/
const templateMiddleware: Template = {
name: 'middleware',
fileSuffix: '.middleware.ts',
description: 'Create a middleware class that can be applied to routes.',
baseAppPath: ['http', 'middlewares'],
render(name: string) {
return `import {Middleware, Injectable} from '@extollo/lib'
/**
* ${name} Middleware
* --------------------------------------------
* Put some description here.
*/
@Injectable()
export class ${name} extends Middleware {
public async apply() {
}
}
`
},
}
export { templateMiddleware }

View File

@@ -1,25 +0,0 @@
import {Template} from '../Template'
/**
* Template that generates a new route definition file in app/http/routes.
*/
const templateRoutes: Template = {
name: 'routes',
fileSuffix: '.routes.ts',
description: 'Create a file for route definitions.',
baseAppPath: ['http', 'routes'],
render(name: string) {
return `import {Route} from '@extollo/lib'
/*
* ${name} Routes
* -------------------------------
* Put some description here.
*/
`
},
}
export { templateRoutes }

View File

@@ -1,36 +0,0 @@
import {Template} from '../Template'
/**
* Template that generates a new application unit class in app/units.
*/
const templateUnit: Template = {
name: 'unit',
fileSuffix: '.ts',
description: 'Create a service unit that will start and stop with your application.',
baseAppPath: ['units'],
render(name: string) {
return `import {Singleton, Inject, Unit, Logging} from '@extollo/lib'
/**
* ${name} Unit
* ---------------------------------------
* Put some description here.
*/
@Singleton()
export class ${name} extends Unit {
@Inject()
protected readonly logging!: Logging
public async up() {
this.logging.info('${name} has started!')
}
public async down() {
this.logging.info('${name} has stopped!')
}
}
`
},
}
export { templateUnit }

View File

@@ -1,11 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"sourceMap": true,
"experimentalDecorators": true
},
"exclude": [
"node_modules"
]
}

View File

@@ -1,325 +0,0 @@
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 {ContainerBlueprint} from './ContainerBlueprint'
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
/**
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Get the global instance of this container.
*/
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Container()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', container)
return container
}
return existing
}
/**
* Collection of factories registered with this container.
* @type Collection<AbstractFactory>
*/
protected factories: Collection<AbstractFactory<unknown>> = new Collection<AbstractFactory<unknown>>()
/**
* Collection of singleton instances produced by this container.
* @type Collection<InstanceRef>
*/
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
constructor() {
this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this)
}
/**
* Purge all factories and instances of the given key from this container.
* @param key
*/
purge(key: DependencyKey): this {
this.factories = this.factories.filter(x => !x.match(key))
this.release(key)
return this
}
/**
* Remove all stored instances of the given key from this container.
* @param key
*/
release(key: DependencyKey): this {
this.instances = this.instances.filter(x => x.key !== key)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
if ( this.resolve(dependency) ) {
throw new DuplicateFactoryKeyError(dependency)
}
const factory = new Factory(dependency)
this.factories.push(factory)
return this
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: DependencyKey, producer: () => any): this {
if ( this.resolve(name) ) {
throw new DuplicateFactoryKeyError(name)
}
const factory = new ClosureFactory(name, producer)
this.factories.push(factory)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
if ( this.resolve(name) ) {
throw new DuplicateFactoryKeyError(name)
}
const factory = new NamedFactory(name, dependency)
this.factories.push(factory)
return this
}
/**
* Register a value as a singleton in the container. It will not be instantiated, but
* can be injected by its unique name.
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton<T>(key: DependencyKey, value: T): this {
if ( this.resolve(key) ) {
throw new DuplicateFactoryKeyError(key)
}
this.factories.push(new SingletonFactory(key, value))
return this
}
/**
* Register a static class to the container along with its already-instantiated
* instance that will be used to resolve the class.
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
if ( this.resolve(staticClass) ) {
throw new DuplicateFactoryKeyError(staticClass)
}
this.register(staticClass)
this.instances.push({
key: staticClass,
value: instance,
})
return this
}
/**
* Register a given factory with the container.
* @param {AbstractFactory} factory
*/
registerFactory(factory: AbstractFactory<unknown>): this {
if ( !this.factories.includes(factory) ) {
this.factories.push(factory)
}
return this
}
/**
* Returns true if the container has an already-produced value for the given key.
* @param {DependencyKey} key
*/
hasInstance(key: DependencyKey): boolean {
return this.instances.where('key', '=', key).isNotEmpty()
}
/**
* Returns true if the container has a factory for the given key.
* @param {DependencyKey} key
*/
hasKey(key: DependencyKey): boolean {
return Boolean(this.resolve(key))
}
/**
* Get the already-produced value for the given key, if one exists.
* @param {DependencyKey} key
*/
getExistingInstance(key: DependencyKey): MaybeDependency {
const instances = this.instances.where('key', '=', key)
if ( instances.isNotEmpty() ) {
return instances.first()
}
}
/**
* Find the factory for the given key, if one is registered with this container.
* @param {DependencyKey} key
*/
resolve(key: DependencyKey): MaybeFactory<unknown> {
const factory = this.factories.firstWhere(item => item.match(key))
if ( factory ) {
return factory
} else {
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
}
}
/**
* Resolve the dependency key. If a singleton value for that key already exists in this container,
* return that value. Otherwise, use the factory an given parameters to produce and return the value.
* @param {DependencyKey} key
* @param {...any} parameters
*/
resolveAndCreate(key: DependencyKey, ...parameters: any[]): any {
logIfDebugging('extollo.di.injector', 'resolveAndCreate', key, {parameters})
// If we've already instantiated this, just return that
const instance = this.getExistingInstance(key)
logIfDebugging('extollo.di.injector', 'resolveAndCreate existing instance?', instance)
if ( typeof instance !== 'undefined' ) {
return instance.value
}
// Otherwise, attempt to create it
const factory = this.resolve(key)
logIfDebugging('extollo.di.injector', 'resolveAndCreate factory', factory)
if ( !factory ) {
throw new InvalidDependencyKeyError(key)
}
// Produce and store a new instance
const newInstance = this.produceFactory(factory, parameters)
this.instances.push({
key,
value: newInstance,
})
return newInstance
}
/**
* Given a factory and manually-provided parameters, resolve the dependencies for the
* factory and produce its value.
* @param {AbstractFactory} factory
* @param {array} parameters
*/
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
// Create the dependencies for the factory
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
const dependencies = keys.map<ResolvedDependency>(req => {
return {
paramIndex: req.paramIndex,
key: req.key,
resolved: this.resolveAndCreate(req.key),
}
}).sortBy('paramIndex')
// Build the arguments for the factory, using dependencies in the
// correct paramIndex positions, or parameters of we don't have
// the dependency.
const constructorArguments = []
const params = collect(parameters).reverse()
for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) {
const dep = dependencies.firstWhere('paramIndex', '=', i)
if ( dep ) {
constructorArguments.push(dep.resolved)
} else {
constructorArguments.push(params.pop())
}
}
// Produce a new instance
const inst = factory.produce(constructorArguments, params.reverse().all())
factory.getInjectedProperties().each(dependency => {
if ( dependency.key && inst ) {
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
}
})
return inst
}
/**
* Create an instance of the given target. The target can either be a DependencyKey registered with
* this container (in which case, the singleton value will be returned), or an instantiable class.
*
* If the instantiable class has the Injectable decorator, its injectable parameters will be automatically
* injected into the instance.
* @param {DependencyKey} target
* @param {...any} parameters
*/
make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( this.hasKey(target) ) {
return this.resolveAndCreate(target, ...parameters)
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
return this.produceFactory(new Factory(target), parameters)
} else {
throw new TypeError(`Invalid or unknown make target: ${target}`)
}
}
/**
* Get a collection of dependency keys required by the given target, if it is registered with this container.
* @param {DependencyKey} target
*/
getDependencies(target: DependencyKey): Collection<DependencyKey> {
const factory = this.resolve(target)
if ( !factory ) {
throw new InvalidDependencyKeyError(target)
}
return factory.getDependencyKeys().pluck('key')
}
/**
* Given a different container, copy the factories and instances from this container over to it.
* @param container
*/
cloneTo(container: Container): this {
container.factories = this.factories.clone()
container.instances = this.instances.clone()
return this
}
}

View File

@@ -1,53 +0,0 @@
import {DependencyKey, Instantiable} from './types'
import NamedFactory from './factory/NamedFactory'
import {AbstractFactory} from './factory/AbstractFactory'
import {Factory} from './factory/Factory'
import {ClosureFactory} from './factory/ClosureFactory'
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>)[] = []
/**
* 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
}
resolve(): AbstractFactory<any>[] {
return this.factories.map(x => x())
}
}

View File

@@ -1,141 +0,0 @@
import {Container, MaybeDependency, MaybeFactory} from './Container'
import {DependencyKey, Instantiable, StaticClass} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
/**
* A container that uses some parent container as a base, but
* can have other factories distinct from that parent.
*
* If an instance is not found in this container, it will be resolved from
* the parent container.
*
* However, if an instance IS found in this container, it will ALWAYS be
* resolved from this container, rather than the parent.
*
* This can be used to create scope-specific containers that can still resolve
* the global dependencies, while keeping scope-specific dependencies separate.
*
* @example
* The Request class from @extollo/lib is a ScopedContainer. It can resolve
* all dependencies that exist in the global Container, but it can also have
* request-specific services (like the Session) injected into it.
*
* @extends Container
*/
export class ScopedContainer extends Container {
/**
* Create a new scoped container based on a parent container instance.
* @param container
*/
public static fromParent(container: Container): ScopedContainer {
return new ScopedContainer(container)
}
private resolveParentScope = true
constructor(
private parentContainer: Container,
) {
super()
this.registerSingletonInstance<ScopedContainer>(ScopedContainer, this)
}
hasInstance(key: DependencyKey): boolean {
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
}
hasKey(key: DependencyKey): boolean {
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
}
getExistingInstance(key: DependencyKey): MaybeDependency {
const inst = super.getExistingInstance(key)
if ( inst ) {
return inst
}
if ( this.resolveParentScope ) {
return this.parentContainer.getExistingInstance(key)
}
}
resolve(key: DependencyKey): MaybeFactory<any> {
const factory = super.resolve(key)
if ( factory ) {
return factory
}
if ( this.resolveParentScope ) {
return this.parentContainer.resolve(key)
}
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.register(dependency))
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: DependencyKey, producer: () => any): this {
return this.withoutParentScopes(() => super.registerProducer(name, producer))
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
}
/**
* Register a value as a singleton in the container. It will not be instantiated, but
* can be injected by its unique name.
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton<T>(key: DependencyKey, value: T): this {
return this.withoutParentScopes(() => super.registerSingleton(key, value))
}
/**
* Register a static class to the container along with its already-instantiated
* instance that will be used to resolve the class.
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
}
/**
* Register a given factory with the container.
* @param {AbstractFactory} factory
*/
registerFactory(factory: AbstractFactory<unknown>): this {
return this.withoutParentScopes(() => super.registerFactory(factory))
}
/**
* Execute a closure on this container, disabling parent-resolution.
* Effectively, the closure will have access to this container as if
* it were NOT a scoped container, and only contained its factories.
* @param closure
*/
withoutParentScopes<T>(closure: () => T): T {
const oldResolveParentScope = this.resolveParentScope
this.resolveParentScope = false
const value: T = closure()
this.resolveParentScope = oldResolveParentScope
return value
}
}

View File

@@ -1,154 +0,0 @@
import 'reflect-metadata'
import {collect, Collection} from '../../util'
import {
DependencyKey,
DependencyRequirement,
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
isInstantiable,
InjectionType,
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
PropertyDependency,
} from '../types'
import {ContainerBlueprint} from '../ContainerBlueprint'
/**
* Get a collection of dependency requirements for the given target object.
* @param {Object} target
* @return Collection<DependencyRequirement>
*/
function initDependencyMetadata(target: unknown): Collection<DependencyRequirement> {
const paramTypes = Reflect.getMetadata('design:paramtypes', target as any)
return collect<DependencyKey>(paramTypes).map<DependencyRequirement>((type, idx) => {
return {
paramIndex: idx,
key: type,
overridden: false,
}
})
}
/**
* Class decorator that marks a class as injectable. When this is applied, dependency
* metadata for the constructors params is resolved and stored in metadata.
* @constructor
*/
export const Injectable = (): ClassDecorator => {
return (target) => {
const meta = initDependencyMetadata(target)
const existing = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
const newMetadata = new Collection<DependencyRequirement>()
if ( existing ) {
const maxNew = meta.max('paramIndex')
const maxExisting = existing.max('paramIndex')
for ( let i = 0; i <= Math.max(maxNew, maxExisting); i++ ) {
const existingDR = existing.firstWhere('paramIndex', '=', i)
const newDR = meta.firstWhere('paramIndex', '=', i)
if ( existingDR && !newDR ) {
newMetadata.push(existingDR)
} else if ( newDR && !existingDR ) {
newMetadata.push(newDR)
} else if ( newDR && existingDR ) {
if ( existingDR.overridden ) {
newMetadata.push(existingDR)
} else {
newMetadata.push(newDR)
}
}
}
} else {
newMetadata.concat(meta)
}
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, newMetadata, target)
}
}
/**
* Mark the given class property to be injected by the container.
* If a `key` is specified, that DependencyKey will be injected.
* Otherwise, the DependencyKey is inferred from the type annotation.
* @param key
* @constructor
*/
export const Inject = (key?: DependencyKey): PropertyDecorator => {
return (target, property) => {
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
if ( !propertyMetadata ) {
propertyMetadata = new Collection<PropertyDependency>()
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
}
const type = Reflect.getMetadata('design:type', target, property)
if ( !key && type ) {
key = type
}
if ( key ) {
const existing = propertyMetadata.firstWhere('property', '=', property)
if ( existing ) {
existing.key = key
} else {
propertyMetadata.push({ property,
key })
}
}
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
}
}
/**
* Parameter decorator to manually mark a parameter as being an injection target on injectable
* classes. This can be used to override the dependency key of a given parameter.
* @param {DependencyKey} key
* @constructor
*/
export const InjectParam = (key: DependencyKey): ParameterDecorator => {
return (target, property, paramIndex) => {
if ( !Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target) ) {
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, initDependencyMetadata(target), target)
}
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
const req = meta.firstWhere('paramIndex', '=', paramIndex)
if ( req ) {
req.key = key
req.overridden = true
} else {
meta.push({
paramIndex,
key,
overridden: true,
})
}
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, meta, target)
}
}
/**
* Class decorator that registers the class as a singleton instance in the global container.
* @param {string} name
*/
export const Singleton = (name?: string): ClassDecorator => {
return (target) => {
if ( isInstantiable(target) ) {
const injectionType: InjectionType = {
type: name ? 'named' : 'singleton',
...(name ? { name } : {}),
}
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
Injectable()(target)
if ( name ) {
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
} else {
ContainerBlueprint.getContainerBlueprint().register(target)
}
}
}
}

View File

@@ -1,11 +0,0 @@
import {DependencyKey} from '../types'
/**
* Error thrown when a factory is registered with a duplicate dependency key.
* @extends Error
*/
export class DuplicateFactoryKeyError extends Error {
constructor(key: DependencyKey) {
super(`A factory definition already exists with the key for ${key}.`)
}
}

View File

@@ -1,11 +0,0 @@
import {DependencyKey} from '../types'
/**
* Error thrown when a dependency key that has not been registered is passed to a resolver.
* @extends Error
*/
export class InvalidDependencyKeyError extends Error {
constructor(key: DependencyKey) {
super(`No such dependency is registered with this container: ${key}`)
}
}

View File

@@ -1,44 +0,0 @@
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
import { Collection } from '../../util'
/**
* Abstract base class for dependency container factories.
* @abstract
*/
export abstract class AbstractFactory<T> {
protected constructor(
/**
* Token that was registered for this factory. In most cases, this is the static
* form of the item that is to be produced by this factory.
* @var
* @protected
*/
protected token: DependencyKey,
) {}
/**
* Produce an instance of the token.
* @param {Array} dependencies - the resolved dependencies, in order
* @param {Array} parameters - the bound constructor parameters, in order
*/
abstract produce(dependencies: any[], parameters: any[]): T
/**
* Should return true if the given identifier matches the token for this factory.
* @param something
* @return boolean
*/
abstract match(something: unknown): boolean
/**
* Get the dependency requirements required by this factory's token.
* @return Collection<DependencyRequirement>
*/
abstract getDependencyKeys(): Collection<DependencyRequirement>
/**
* Get the property dependencies that should be injected to the created instance.
* @return Collection<PropertyDependency>
*/
abstract getInjectedProperties(): Collection<PropertyDependency>
}

View File

@@ -1,43 +0,0 @@
import {AbstractFactory} from './AbstractFactory'
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
import {Collection} from '../../util'
/**
* A factory whose token is produced by calling a function.
*
* @example
* ```typescript
* let i = 0
* const fact = new ClosureFactory('someName', () => {
* i += 1
* return i * 2
* })
*
* fact.produce([], []) // => 2
* fact.produce([], []) // => 4
* ```
*/
export class ClosureFactory<T> extends AbstractFactory<T> {
constructor(
protected readonly name: DependencyKey,
protected readonly token: () => T,
) {
super(token)
}
produce(): any {
return this.token()
}
match(something: unknown): boolean {
return something === this.name
}
getDependencyKeys(): Collection<DependencyRequirement> {
return new Collection<DependencyRequirement>()
}
getInjectedProperties(): Collection<PropertyDependency> {
return new Collection<PropertyDependency>()
}
}

View File

@@ -1,69 +0,0 @@
import {AbstractFactory} from './AbstractFactory'
import {
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
DependencyRequirement,
Instantiable,
PropertyDependency,
} from '../types'
import {Collection} from '../../util'
import 'reflect-metadata'
/**
* Standard static-class factory. The token of this factory is a reference to a
* static class that is instantiated when the factory produces.
*
* Dependency keys are inferred from injection metadata on the constructor's params,
* as are the injected properties.
*
* @example
* ```typescript
* class A {
* constructor(
* protected readonly myService: MyService
* ) { }
* }
*
* const fact = new Factory(A)
*
* fact.produce([myServiceInstance], []) // => A { myService: myServiceInstance }
* ```
*/
export class Factory<T> extends AbstractFactory<T> {
constructor(
protected readonly token: Instantiable<T>,
) {
super(token)
}
produce(dependencies: any[], parameters: any[]): any {
return new this.token(...dependencies, ...parameters)
}
match(something: unknown): boolean {
return something === this.token // || (something?.name && something.name === this.token.name)
}
getDependencyKeys(): Collection<DependencyRequirement> {
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.token)
if ( meta ) {
return meta
}
return new Collection<DependencyRequirement>()
}
getInjectedProperties(): Collection<PropertyDependency> {
const meta = new Collection<PropertyDependency>()
let currentToken = this.token
do {
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
if ( loadedMeta ) {
meta.concat(loadedMeta)
}
currentToken = Object.getPrototypeOf(currentToken)
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
return meta
}
}

View File

@@ -1,29 +0,0 @@
import {Factory} from './Factory'
import {Instantiable} from '../types'
/**
* Container factory that produces an instance of the token, however the token
* is identified by a string name rather than a class reference.
* @extends Factory
*/
export default class NamedFactory<T> extends Factory<T> {
constructor(
/**
* The name identifying this factory in the container.
* @type {string}
*/
protected name: string,
/**
* The token to be instantiated.
* @type {Instantiable}
*/
protected token: Instantiable<T>,
) {
super(token)
}
match(something: unknown): boolean {
return something === this.name
}
}

View File

@@ -1,48 +0,0 @@
import { Factory } from './Factory'
import { Collection } from '../../util'
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types'
/**
* Container factory which returns its token as its value, without attempting
* to instantiate anything. This is used to register already-produced-singletons
* with the container.
*
* @example
* ```typescript
* class A {}
* const exactlyThisInstanceOfA = new A()
*
* const fact = new SingletonFactory(A, a)
*
* fact.produce([], []) // => exactlyThisInstanceOfA
* ```
*
* @extends Factory
*/
export default class SingletonFactory<T> extends Factory<T> {
constructor(
/**
* Token identifying this singleton.
*/
protected token: DependencyKey,
/**
* The value of this singleton.
*/
protected value: T,
) {
super(token)
}
produce(): T {
return this.value
}
getDependencyKeys(): Collection<DependencyRequirement> {
return new Collection<DependencyRequirement>()
}
getInjectedProperties(): Collection<PropertyDependency> {
return new Collection<PropertyDependency>()
}
}

View File

@@ -1,15 +0,0 @@
export * from './error/DuplicateFactoryKeyError'
export * from './error/InvalidDependencyKeyError'
export * from './factory/AbstractFactory'
export * from './factory/ClosureFactory'
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'

View File

@@ -1,77 +0,0 @@
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.
*/
export interface Instantiable<T> {
new(...args: any[]): T
}
/**
* Returns true if the given value is instantiable.
* @param what
*/
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'
)
}
/**
* Type that identifies a value as a static class, even if it is not instantiable.
*/
export type StaticClass<T, T2> = Function & {prototype: T} & T2 // eslint-disable-line @typescript-eslint/ban-types
/**
* Returns true if the parameter is a static class.
* @param something
*/
export function isStaticClass<T, T2>(something: unknown): something is StaticClass<T, T2> {
return typeof something === 'function' && typeof something.prototype !== 'undefined'
}
/**
* Type used to represent a value that can identify a factory in the container.
*/
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
/**
* Interface used to store dependency requirements by their place in the injectable
* target's parameters.
*/
export interface DependencyRequirement {
paramIndex: number,
key: DependencyKey,
overridden: boolean,
}
/**
* Interface used to store dependency requirements by the class property they should
* be injected into.
*/
export interface PropertyDependency {
key: DependencyKey,
property: string | symbol,
}
/**
* Interface used to keep track of singleton factory values, by their dependency key.
*/
export interface InstanceRef {
key: DependencyKey,
value: any,
}
/**
* Interface used to keep track of the injection type of a class.
*/
export interface InjectionType {
type: 'named' | 'singleton',
name?: string,
}

View File

@@ -1,13 +0,0 @@
import {Dispatchable} from './types'
import {Awaitable, JSONState} from '../util'
/**
* Abstract class representing an event that may be fired.
*/
export abstract class Event implements Dispatchable {
abstract dehydrate(): Awaitable<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
}

View File

@@ -1,53 +0,0 @@
import {Instantiable, Singleton, StaticClass} from '../di'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
import {Awaitable, Collection, uuid4} from '../util'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
*/
@Singleton()
export class EventBus implements Bus {
/**
* Collection of subscribers, by their events.
* @protected
*/
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<T> = {
id: uuid4(),
event,
subscriber,
}
this.subscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.subscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
}
/**
* Build an EventSubscription object for the subscriber of the given ID.
* @param id
* @protected
*/
protected buildSubscription(id: string): EventSubscription {
let subscribed = true
return {
unsubscribe: (): Awaitable<void> => {
if ( subscribed ) {
this.subscribers = this.subscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
}

View File

@@ -1,28 +0,0 @@
import {EventBus} from './EventBus'
import {Collection} from '../util'
import {Bus, Dispatchable} from './types'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
* This bus also supports "propagating" events along to any other connected buses.
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
* level bus whose events still reach the global EventBus instance.
*/
export class PropagatingEventBus extends EventBus {
protected recipients: Collection<Bus> = new Collection<Bus>()
async dispatch(event: Dispatchable): Promise<void> {
await super.dispatch(event)
await this.recipients.promiseMap(bus => bus.dispatch(event))
}
/**
* Register the given bus to receive events fired on this bus.
* @param recipient
*/
connect(recipient: Bus): void {
if ( !this.recipients.includes(recipient) ) {
this.recipients.push(recipient)
}
}
}

View File

@@ -1,47 +0,0 @@
import {Awaitable, Rehydratable} from '../util'
import {Instantiable, StaticClass} from '../di'
/**
* A closure that should be executed with the given event is fired.
*/
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
/**
* An object used to track event subscriptions internally.
*/
export interface EventSubscriberEntry<T extends Dispatchable> {
/** Globally unique ID of this subscription. */
id: string
/** The event class subscribed to. */
event: StaticClass<T, Instantiable<T>>
/** The closure to execute when the event is fired. */
subscriber: EventSubscriber<T>
}
/**
* An object returned upon subscription, used to unsubscribe.
*/
export interface EventSubscription {
/**
* Unsubscribe the associated listener from the event bus.
*/
unsubscribe(): Awaitable<void>
}
/**
* An instance of something that can be fired on an event bus.
*/
export interface Dispatchable extends Rehydratable {
shouldQueue?: boolean
}
/**
* An event-driven bus that manages subscribers and dispatched items.
*/
export interface Bus {
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
dispatch(event: Dispatchable): Awaitable<void>
}

View File

View File

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

View File

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

View File

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

View File

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

View File

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

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