9 Commits
0.3.0 ... 0.3.1

Author SHA1 Message Date
a69c81ed35 chore(version): 0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-17 19:35:50 -05:00
36b451c32b Expose auth repos in context; create routes commands
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-17 19:35:31 -05:00
9796a7277e Begin abstracting global container into injector 2021-06-17 19:34:32 -05:00
f00233d49a Add middleware and logic for bootstrapping the session auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 13:24:12 -05:00
91abcdf8ef Start auth framework
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 12:02:36 -05:00
c264d45927 Add query executed event; forward model events to global event bus
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 08:36:35 -05:00
61731c4ebd Add basic concepts for event bus, and implement in request and model
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-04 01:03:31 -05:00
dab3d006c8 Containers - add ability to purge/release factories; override factories in scoped 2021-06-04 01:03:10 -05:00
cd9bec7c5e Remove old doc build trigger from CI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-06-02 22:45:49 -05:00
58 changed files with 1782 additions and 92 deletions

View File

@@ -217,18 +217,3 @@ steps:
when:
status: failure
event: pull_request
- name: trigger documentation build
image: plugins/downstream
settings:
server: https://ci.garrettmills.dev
token:
from_secret: drone_token
fork: false
last_successful: true
deploy: production
repositories:
- Extollo/docs@master
when:
status: success
event: tag

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.3.0",
"version": "0.3.1",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -8,7 +8,9 @@
"lib": "lib"
},
"dependencies": {
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1",
"@types/node": "^14.14.37",
@@ -18,7 +20,9 @@
"@types/rimraf": "^3.0.0",
"@types/ssh2": "^0.5.46",
"@types/uuid": "^8.3.0",
"bcrypt": "^5.0.1",
"busboy": "^0.3.1",
"cli-table": "^0.3.6",
"colors": "^1.4.0",
"dotenv": "^8.2.0",
"mkdirp": "^1.0.4",

307
pnpm-lock.yaml generated
View File

@@ -1,5 +1,7 @@
dependencies:
'@types/bcrypt': 5.0.0
'@types/busboy': 0.2.3
'@types/cli-table': 0.3.0
'@types/mkdirp': 1.0.1
'@types/negotiator': 0.6.1
'@types/node': 14.14.37
@@ -9,7 +11,9 @@ dependencies:
'@types/rimraf': 3.0.0
'@types/ssh2': 0.5.46
'@types/uuid': 8.3.0
bcrypt: 5.0.1
busboy: 0.3.1
cli-table: 0.3.6
colors: 1.4.0
dotenv: 8.2.0
mkdirp: 1.0.4
@@ -85,6 +89,21 @@ packages:
node: ^10.12.0 || >=12.0.0
resolution:
integrity: sha512-5v7TDE9plVhvxQeWLXDTvFvJBdH6pEsdnl2g/dAptmuFEPedQ4Erq5rsDsX+mvAM610IhNaO2W5V1dOOnDKxkQ==
/@mapbox/node-pre-gyp/1.0.5:
dependencies:
detect-libc: 1.0.3
https-proxy-agent: 5.0.0
make-dir: 3.1.0
node-fetch: 2.6.1
nopt: 5.0.0
npmlog: 4.1.2
rimraf: 3.0.2
semver: 7.3.5
tar: 6.1.0
dev: false
hasBin: true
resolution:
integrity: sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==
/@nodelib/fs.scandir/2.1.4:
dependencies:
'@nodelib/fs.stat': 2.0.4
@@ -109,12 +128,22 @@ packages:
node: '>= 8'
resolution:
integrity: sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==
/@types/bcrypt/5.0.0:
dependencies:
'@types/node': 14.17.2
dev: false
resolution:
integrity: sha512-agtcFKaruL8TmcvqbndlqHPSJgsolhf/qPWchFlgnW1gECTN/nKbFcoFnvKAQRFfKbh+BO6A3SWdJu9t+xF3Lw==
/@types/busboy/0.2.3:
dependencies:
'@types/node': 14.14.37
dev: false
resolution:
integrity: sha1-ZpetKYcyRsUw8Jo/9aQIYYJCMNU=
/@types/cli-table/0.3.0:
dev: false
resolution:
integrity: sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==
/@types/glob/7.1.3:
dependencies:
'@types/minimatch': 3.0.4
@@ -148,6 +177,10 @@ packages:
dev: false
resolution:
integrity: sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw==
/@types/node/14.17.2:
dev: false
resolution:
integrity: sha512-sld7b/xmFum66AAKuz/rp/CUO8+98fMpyQ3SBfzzBNGMd/1iHBTAg9oyAvcYlAj46bpc74r91jSw2iFdnx29nw==
/@types/pg/8.6.0:
dependencies:
'@types/node': 14.17.1
@@ -293,6 +326,10 @@ packages:
node: ^8.10.0 || ^10.13.0 || >=11.10.1
resolution:
integrity: sha512-cw4j8lH38V1ycGBbF+aFiLUls9Z0Bw8QschP3mkth50BbWzgFS33ISIgBzUMuQ2IdahoEv/rXstr8Zhlz4B1Zg==
/abbrev/1.1.1:
dev: false
resolution:
integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
/acorn-jsx/5.3.1_acorn@7.4.1:
dependencies:
acorn: 7.4.1
@@ -307,6 +344,14 @@ packages:
hasBin: true
resolution:
integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
/agent-base/6.0.2:
dependencies:
debug: 4.3.1
dev: false
engines:
node: '>= 6.0.0'
resolution:
integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
/ajv/6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -331,6 +376,12 @@ packages:
node: '>=6'
resolution:
integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
/ansi-regex/2.1.1:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
/ansi-regex/5.0.0:
dev: true
engines:
@@ -353,6 +404,17 @@ packages:
node: '>=8'
resolution:
integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
/aproba/1.2.0:
dev: false
resolution:
integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
/are-we-there-yet/1.1.5:
dependencies:
delegates: 1.0.0
readable-stream: 2.3.7
dev: false
resolution:
integrity: sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
/arg/4.1.3:
dev: false
resolution:
@@ -412,6 +474,16 @@ packages:
dev: false
resolution:
integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
/bcrypt/5.0.1:
dependencies:
'@mapbox/node-pre-gyp': 1.0.5
node-addon-api: 3.2.1
dev: false
engines:
node: '>= 10.0.0'
requiresBuild: true
resolution:
integrity: sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==
/brace-expansion/1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -482,6 +554,26 @@ packages:
dev: false
resolution:
integrity: sha1-x84o821LzZdE5f/CxfzeHHMmH8A=
/chownr/2.0.0:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
/cli-table/0.3.6:
dependencies:
colors: 1.0.3
dev: false
engines:
node: '>= 0.2.0'
resolution:
integrity: sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
/code-point-at/1.1.0:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
/color-convert/1.9.3:
dependencies:
color-name: 1.1.3
@@ -504,6 +596,12 @@ packages:
dev: true
resolution:
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
/colors/1.0.3:
dev: false
engines:
node: '>=0.1.90'
resolution:
integrity: sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
/colors/1.4.0:
dev: false
engines:
@@ -517,6 +615,10 @@ packages:
/concat-map/0.0.1:
resolution:
integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
/console-control-strings/1.1.0:
dev: false
resolution:
integrity: sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
/constantinople/4.0.1:
dependencies:
'@babel/parser': 7.13.13
@@ -524,6 +626,10 @@ packages:
dev: false
resolution:
integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==
/core-util-is/1.0.2:
dev: false
resolution:
integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
/cpu-features/0.0.2:
dependencies:
nan: 2.14.2
@@ -551,7 +657,6 @@ packages:
/debug/4.3.1:
dependencies:
ms: 2.1.2
dev: true
engines:
node: '>=6.0'
peerDependencies:
@@ -565,6 +670,17 @@ packages:
dev: true
resolution:
integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
/delegates/1.0.0:
dev: false
resolution:
integrity: sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
/detect-libc/1.0.3:
dev: false
engines:
node: '>=0.10'
hasBin: true
resolution:
integrity: sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
/dicer/0.3.0:
dependencies:
streamsearch: 0.1.2
@@ -838,6 +954,14 @@ packages:
node: '>=10'
resolution:
integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==
/fs-minipass/2.1.0:
dependencies:
minipass: 3.1.3
dev: false
engines:
node: '>= 8'
resolution:
integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
/fs.realpath/1.0.0:
resolution:
integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
@@ -849,6 +973,19 @@ packages:
dev: true
resolution:
integrity: sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
/gauge/2.7.4:
dependencies:
aproba: 1.2.0
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
signal-exit: 3.0.3
string-width: 1.0.2
strip-ansi: 3.0.1
wide-align: 1.1.3
dev: false
resolution:
integrity: sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
/get-intrinsic/1.1.1:
dependencies:
function-bind: 1.1.1
@@ -940,6 +1077,10 @@ packages:
node: '>= 0.4'
resolution:
integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
/has-unicode/2.0.1:
dev: false
resolution:
integrity: sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
/has/1.0.3:
dependencies:
function-bind: 1.1.1
@@ -948,6 +1089,15 @@ packages:
node: '>= 0.4.0'
resolution:
integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
/https-proxy-agent/5.0.0:
dependencies:
agent-base: 6.0.2
debug: 4.3.1
dev: false
engines:
node: '>= 6'
resolution:
integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
/ignore/4.0.6:
dev: true
engines:
@@ -1009,6 +1159,14 @@ packages:
node: '>=0.10.0'
resolution:
integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
/is-fullwidth-code-point/1.0.0:
dependencies:
number-is-nan: 1.0.1
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
/is-fullwidth-code-point/3.0.0:
dev: true
engines:
@@ -1042,6 +1200,10 @@ packages:
node: '>= 0.4'
resolution:
integrity: sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==
/isarray/1.0.0:
dev: false
resolution:
integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
/isexe/2.0.0:
dev: true
resolution:
@@ -1122,7 +1284,6 @@ packages:
/lru-cache/6.0.0:
dependencies:
yallist: 4.0.0
dev: true
engines:
node: '>=10'
resolution:
@@ -1131,6 +1292,14 @@ packages:
dev: false
resolution:
integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==
/make-dir/3.1.0:
dependencies:
semver: 6.3.0
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
/make-error/1.3.6:
dev: false
resolution:
@@ -1166,6 +1335,23 @@ packages:
dev: false
resolution:
integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
/minipass/3.1.3:
dependencies:
yallist: 4.0.0
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
/minizlib/2.1.2:
dependencies:
minipass: 3.1.3
yallist: 4.0.0
dev: false
engines:
node: '>= 8'
resolution:
integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
/mkdirp/1.0.4:
dev: false
engines:
@@ -1174,7 +1360,6 @@ packages:
resolution:
integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
/ms/2.1.2:
dev: true
resolution:
integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
/nan/2.14.2:
@@ -1196,6 +1381,40 @@ packages:
dev: false
resolution:
integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
/node-addon-api/3.2.1:
dev: false
resolution:
integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==
/node-fetch/2.6.1:
dev: false
engines:
node: 4.x || >=6.0.0
resolution:
integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
/nopt/5.0.0:
dependencies:
abbrev: 1.1.1
dev: false
engines:
node: '>=6'
hasBin: true
resolution:
integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
/npmlog/4.1.2:
dependencies:
are-we-there-yet: 1.1.5
console-control-strings: 1.1.0
gauge: 2.7.4
set-blocking: 2.0.0
dev: false
resolution:
integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
/number-is-nan/1.0.1:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
/object-assign/4.1.1:
dev: false
engines:
@@ -1362,6 +1581,10 @@ packages:
node: '>= 0.8.0'
resolution:
integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
/process-nextick-args/2.0.1:
dev: false
resolution:
integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
/progress/2.0.3:
engines:
node: '>=0.4.0'
@@ -1474,6 +1697,18 @@ packages:
dev: true
resolution:
integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
/readable-stream/2.3.7:
dependencies:
core-util-is: 1.0.2
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
dev: false
resolution:
integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
/readable-stream/3.6.0:
dependencies:
inherits: 2.0.4
@@ -1540,6 +1775,10 @@ packages:
dev: true
resolution:
integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
/safe-buffer/5.1.2:
dev: false
resolution:
integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
/safe-buffer/5.2.1:
dev: false
resolution:
@@ -1548,15 +1787,23 @@ packages:
dev: false
resolution:
integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
/semver/6.3.0:
dev: false
hasBin: true
resolution:
integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
/semver/7.3.5:
dependencies:
lru-cache: 6.0.0
dev: true
engines:
node: '>=10'
hasBin: true
resolution:
integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
/set-blocking/2.0.0:
dev: false
resolution:
integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
/shebang-command/2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -1589,6 +1836,10 @@ packages:
dev: false
resolution:
integrity: sha512-NEjg1mVbAUrzRv2eIcUt3TG7X9svX7l3n3F5/3OdFq+/BxUdmBOeKGiH4icZJBLHy354Shnj6sfBTemea2e7XA==
/signal-exit/3.0.3:
dev: false
resolution:
integrity: sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
/slash/3.0.0:
dev: true
engines:
@@ -1647,6 +1898,16 @@ packages:
node: '>=0.8.0'
resolution:
integrity: sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
/string-width/1.0.2:
dependencies:
code-point-at: 1.1.0
is-fullwidth-code-point: 1.0.0
strip-ansi: 3.0.1
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
/string-width/4.2.2:
dependencies:
emoji-regex: 8.0.0
@@ -1657,12 +1918,26 @@ packages:
node: '>=8'
resolution:
integrity: sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
/string_decoder/1.1.1:
dependencies:
safe-buffer: 5.1.2
dev: false
resolution:
integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
/string_decoder/1.3.0:
dependencies:
safe-buffer: 5.2.1
dev: false
resolution:
integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
/strip-ansi/3.0.1:
dependencies:
ansi-regex: 2.1.1
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
/strip-ansi/6.0.0:
dependencies:
ansi-regex: 5.0.0
@@ -1706,6 +1981,19 @@ packages:
node: '>=10.0.0'
resolution:
integrity: sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==
/tar/6.1.0:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 3.1.3
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
dev: false
engines:
node: '>= 10'
resolution:
integrity: sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==
/text-table/0.2.0:
dev: true
resolution:
@@ -1894,6 +2182,12 @@ packages:
hasBin: true
resolution:
integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
/wide-align/1.1.3:
dependencies:
string-width: 1.0.2
dev: false
resolution:
integrity: sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
/with/7.0.2:
dependencies:
'@babel/parser': 7.13.13
@@ -1929,7 +2223,6 @@ packages:
resolution:
integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
/yallist/4.0.0:
dev: true
resolution:
integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
/yn/3.1.1:
@@ -1939,7 +2232,9 @@ packages:
resolution:
integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
specifiers:
'@types/bcrypt': ^5.0.0
'@types/busboy': ^0.2.3
'@types/cli-table': ^0.3.0
'@types/mkdirp': ^1.0.1
'@types/negotiator': ^0.6.1
'@types/node': ^14.14.37
@@ -1951,7 +2246,9 @@ specifiers:
'@types/uuid': ^8.3.0
'@typescript-eslint/eslint-plugin': ^4.26.0
'@typescript-eslint/parser': ^4.26.0
bcrypt: ^5.0.1
busboy: ^0.3.1
cli-table: ^0.3.6
colors: ^1.4.0
dotenv: ^8.2.0
eslint: ^7.27.0

View File

@@ -0,0 +1,40 @@
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
import {Unit} from '../lifecycle/Unit'
import {Logging} from '../service/Logging'
import {CanonicalResolver} from '../service/Canonical'
import {Middleware} from '../http/routing/Middleware'
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
import {Middlewares} from '../service/Middlewares'
/**
* Unit class that bootstraps the authentication framework.
*/
@Injectable()
export class Authentication extends Unit {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly middleware!: Middlewares
async up(): Promise<void> {
this.container()
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
}
/**
* Create the canonical namespace resolver for auth middleware.
* @protected
*/
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
return (key: string) => {
return ({
web: SessionAuthMiddleware,
required: AuthRequiredMiddleware,
guest: GuestRequiredMiddleware,
})[key]
}
}
}

View File

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

143
src/auth/SecurityContext.ts Normal file
View File

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

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

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

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

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a user is authenticated.
*/
export class UserAuthenticatedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

View File

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a security context for a given user is resumed.
*/
export class UserAuthenticationResumedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

View File

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a user is unauthenticated.
*/
export class UserFlushedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

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

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

View File

@@ -0,0 +1,19 @@
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'
@Injectable()
export class AuthRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}

View File

@@ -0,0 +1,19 @@
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'
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}

View File

@@ -0,0 +1,35 @@
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'
/**
* 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
async apply(): Promise<ResponseObject> {
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
this.request.registerSingletonInstance(SecurityContext, context)
await context.resume()
}
/**
* Build the correct AuthenticatableRepository based on the auth config.
* @protected
*/
protected getRepository(): AuthenticatableRepository {
const config: AuthConfig | undefined = this.config.get('auth')
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
}
}

64
src/auth/orm/ORMUser.ts Normal file
View File

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

View File

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

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

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

@@ -0,0 +1,71 @@
import {Directive, OptionDefinition} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
import {RouteHandler} from '../../http/routing/Route'
@Injectable()
export class RouteDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'Get information about a specific route'
}
getKeywords(): string | string[] {
return ['route']
}
getOptions(): OptionDefinition[] {
return [
'{route} | the path of the route',
'--method -m {value} | the HTTP method of the route',
]
}
async handle(): Promise<void> {
const method: string | undefined = this.option('method')
?.toLowerCase()
?.trim()
const route: string = this.option('route')
.toLowerCase()
.trim()
this.routing.getCompiled()
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
.tap(matches => {
if ( !matches.length ) {
this.error('No matching routes found. (Use `./ex routes` to list)')
process.exitCode = 1
}
})
.each(match => {
const pre = match.getMiddlewares()
.where('stage', '=', 'pre')
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
const post = match.getMiddlewares()
.where('stage', '=', 'post')
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
const table = new Table({
head: ['Stage', 'Handler'],
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
})
table.push(...pre.toArray())
table.push(['handler', match.getDisplayableHandler()])
table.push(...post.toArray())
this.info(`\nRoute: ${match}\n\n${table}`)
})
}
protected handlerToString(handler: RouteHandler): string {
return typeof handler === 'string' ? handler : '(anonymous function)'
}
}

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ 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
@@ -42,6 +44,8 @@ export class CommandLineApplication extends Unit {
this.cli.registerDirective(ShellDirective)
this.cli.registerDirective(TemplateDirective)
this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()

View File

@@ -1,4 +1,4 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable} from './types'
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'
@@ -7,6 +7,7 @@ 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
@@ -23,6 +24,11 @@ export class 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
}
@@ -47,6 +53,25 @@ export class Container {
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
@@ -113,7 +138,7 @@ export class Container {
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T): this {
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
if ( this.resolve(staticClass) ) {
throw new DuplicateFactoryKeyError(staticClass)
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
PropertyDependency,
} from '../types'
import {Container} from '../Container'
import {ContainerBlueprint} from '../ContainerBlueprint'
/**
* Get a collection of dependency requirements for the given target object.
@@ -145,9 +145,9 @@ export const Singleton = (name?: string): ClassDecorator => {
Injectable()(target)
if ( name ) {
Container.getContainer().registerNamed(name, target)
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
} else {
Container.getContainer().register(target)
ContainerBlueprint.getContainerBlueprint().register(target)
}
}
}

View File

@@ -7,6 +7,7 @@ export * from './factory/Factory'
export * from './factory/NamedFactory'
export * from './factory/SingletonFactory'
export * from './ContainerBlueprint'
export * from './Container'
export * from './ScopedContainer'
export * from './types'

11
src/event/Event.ts Normal file
View File

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

53
src/event/EventBus.ts Normal file
View File

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

View File

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

47
src/event/types.ts Normal file
View File

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

View File

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

View File

@@ -224,6 +224,34 @@ export class Route extends AppClass {
super()
}
/**
* Get the string-form of the route.
*/
public getRoute(): string {
return this.route
}
/**
* Get the string-form method of the route.
*/
public getMethod(): HTTPMethod | HTTPMethod[] {
return this.method
}
/**
* Get collection of applied middlewares.
*/
public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
return this.middlewares.clone()
}
/**
* Get the string-form of the route handler.
*/
public getDisplayableHandler(): string {
return typeof this.handler === 'string' ? this.handler : '(anonymous function)'
}
/**
* Returns true if this route matches the given HTTP verb and request path.
* @param method

View File

@@ -1,6 +1,11 @@
export * from './util'
export * from './di'
export * from './event/types'
export * from './event/Event'
export * from './event/EventBus'
export * from './event/PropagatingEventBus'
export * from './service/Logging'
export * from './lifecycle/RunLevelErrorHandler'
@@ -74,3 +79,4 @@ export * from './cli'
export * from './i18n'
export * from './forms'
export * from './orm'
export * from './auth'

View File

@@ -1,5 +1,5 @@
import {Application} from './Application'
import {Container, DependencyKey} from '../di'
import {Container, DependencyKey, Injectable} from '../di'
/**
* Base type for a class that supports binding methods by string.
@@ -25,12 +25,11 @@ export function isBindable(what: unknown): what is Bindable {
/**
* Base for classes that gives access to the global application and container.
*/
@Injectable()
export class AppClass {
/** The global application instance. */
private readonly appClassApplication!: Application;
constructor() {
this.appClassApplication = Application.getApplication()
private get appClassApplication(): Application {
return Application.getApplication()
}
/** Get the global Application. */

View File

@@ -1,4 +1,4 @@
import {Container} from '../di'
import {Container, ContainerBlueprint} from '../di'
import {
ErrorWithContext,
globalRegistry,
@@ -52,6 +52,11 @@ export class Application extends Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -74,6 +79,11 @@ export class Application extends Container {
return app
} else {
const app = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => app.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', app)
return app
}

View File

@@ -2,6 +2,9 @@ import {ErrorWithContext} from '../../util'
import {QueryResult} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
/**
* Error thrown when a connection is used before it is ready.
@@ -18,7 +21,10 @@ export class ConnectionNotReadyError extends ErrorWithContext {
* Abstract base class for database connections.
* @abstract
*/
@Injectable()
export abstract class Connection extends AppClass {
@Inject()
protected bus!: EventBus
constructor(
/**
@@ -64,4 +70,14 @@ export abstract class Connection extends AppClass {
// public abstract tables(database_name: string): Promise<Collection<Table>>
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
/**
* Fire a QueryExecutedEvent for the given query string.
* @param query
* @protected
*/
protected async queryExecuted(query: string): Promise<void> {
const event = new QueryExecutedEvent(this.name, this, query)
await this.bus.dispatch(event)
}
}

View File

@@ -54,6 +54,7 @@ export class PostgresConnection extends Connection {
try {
const result = await this.client.query(query)
await this.queryExecuted(query)
return {
rows: collect(result.rows),

View File

@@ -0,0 +1,67 @@
import {Event} from '../../../event/Event'
import {Inject, Injectable} from '../../../di'
import {InvalidJSONStateError, JSONState} from '../../../util'
import {Connection} from '../Connection'
import {DatabaseService} from '../../DatabaseService'
/**
* Event fired when a query is executed.
*/
@Injectable()
export class QueryExecutedEvent extends Event {
@Inject()
protected database!: DatabaseService
/**
* The name of the connection where the query was executed.
* @protected
*/
public connectionName!: string
/**
* The connection where the query was executed.
*/
public connection!: Connection
/**
* The query that was executed.
*/
public query!: string
constructor(
connectionName?: string,
connection?: Connection,
query?: string,
) {
super()
if ( connectionName ) {
this.connectionName = connectionName
}
if ( connection ) {
this.connection = connection
}
if ( query ) {
this.query = query
}
}
async dehydrate(): Promise<JSONState> {
return {
connectionName: this.connectionName,
query: this.query,
}
}
rehydrate(state: JSONState): void {
if ( !state.connectionName || !state.query ) {
throw new InvalidJSONStateError('Missing connectionName or query from QueryExecutedEvent state.')
}
this.query = String(state.query)
this.connectionName = String(state.connectionName)
this.connection = this.database.get(this.connectionName)
}
}

View File

@@ -15,6 +15,7 @@ export * from './model/Field'
export * from './model/ModelBuilder'
export * from './model/ModelBuilder'
export * from './model/ModelResultIterable'
export * from './model/events'
export * from './model/Model'
export * from './services/Database'

View File

@@ -1,20 +1,32 @@
import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject} from '../../di'
import {Container, Inject, StaticClass} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, BehaviorSubject, Pipe, Collection} from '../../util'
import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types'
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
import {ModelSavingEvent} from './events/ModelSavingEvent'
import {ModelSavedEvent} from './events/ModelSavedEvent'
import {ModelUpdatingEvent} from './events/ModelUpdatingEvent'
import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
import {EventBus} from '../../event/EventBus'
/**
* Base for classes that are mapped to tables in a database.
*/
export abstract class Model<T extends Model<T>> extends AppClass {
export abstract class Model<T extends Model<T>> extends AppClass implements Bus {
@Inject()
protected readonly logging!: Logging;
protected readonly logging!: Logging
@Inject()
protected readonly bus!: EventBus
/**
* The name of the connection this model should run through.
@@ -78,49 +90,10 @@ export abstract class Model<T extends Model<T>> extends AppClass {
protected originalSourceRow?: QueryRow
/**
* Behavior subject that fires after the model is populated.
* Collection of event subscribers, by their events.
* @protected
*/
protected retrieved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is saved.
*/
protected saving$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is saved.
*/
protected saved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is updated.
*/
protected updating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is updated.
*/
protected updated$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is inserted.
*/
protected creating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is inserted.
*/
protected created$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is deleted.
*/
protected deleting$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is deleted.
*/
protected deleted$ = new BehaviorSubject<Model<T>>()
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
/**
* Get the table name for this model.
@@ -193,9 +166,16 @@ export abstract class Model<T extends Model<T>> extends AppClass {
values?: {[key: string]: any},
) {
super()
this.initialize()
this.boot(values)
}
/**
* Called when the model is instantiated. Use for any setup of events, &c.
* @protected
*/
protected initialize(): void {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Initialize the model's properties from the given values and do any other initial setup.
*
@@ -228,7 +208,7 @@ export abstract class Model<T extends Model<T>> extends AppClass {
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
})
await this.retrieved$.next(this)
await this.dispatch(new ModelRetrievedEvent<T>(this as any))
return this
}
@@ -592,11 +572,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
* @param withoutTimestamps
*/
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
await this.saving$.next(this)
await this.dispatch(new ModelSavingEvent<T>(this as any))
const ctor = this.constructor as typeof Model
if ( this.exists() && this.isDirty() ) {
await this.updating$.next(this)
await this.dispatch(new ModelUpdatingEvent<T>(this as any))
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
(this as any)[ctor.UPDATED_AT] = new Date()
@@ -617,9 +597,9 @@ export abstract class Model<T extends Model<T>> extends AppClass {
await this.assumeFromSource(data)
}
await this.updated$.next(this)
await this.dispatch(new ModelUpdatedEvent<T>(this as any))
} else if ( !this.exists() ) {
await this.creating$.next(this)
await this.dispatch(new ModelCreatingEvent<T>(this as any))
if ( !withoutTimestamps ) {
if ( ctor.timestamps && ctor.CREATED_AT ) {
@@ -647,10 +627,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
if ( data ) {
await this.assumeFromSource(result)
}
await this.created$.next(this)
await this.dispatch(new ModelCreatedEvent<T>(this as any))
}
await this.saved$.next(this)
await this.dispatch(new ModelSavedEvent<T>(this as any))
return this
}
@@ -822,4 +803,44 @@ export abstract class Model<T extends Model<T>> extends AppClass {
protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void {
(this as any)[thisFieldName] = object[objectFieldName]
}
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, EventT>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<EventT> = {
id: uuid4(),
event,
subscriber,
}
this.modelEventBusSubscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<EventT extends Dispatchable>(subscriber: EventSubscriber<EventT>): Awaitable<void> {
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.modelEventBusSubscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
await this.bus.dispatch(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.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is inserted.
*/
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is inserted.
*/
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is deleted.
*/
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is deleted.
*/
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,31 @@
import {Model} from '../Model'
import {Event} from '../../../event/Event'
import {JSONState} from '../../../util'
/**
* Base class for events that concern an instance of a model.
*/
export abstract class ModelEvent<T extends Model<T>> extends Event {
/**
* The instance of the model.
*/
public instance!: T
constructor(
instance?: T,
) {
super()
if ( instance ) {
this.instance = instance
}
}
// TODO implement serialization here
dehydrate(): Promise<JSONState> {
return Promise.resolve({})
}
rehydrate(/* state: JSONState */): void | Promise<void> {
return undefined
}
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model's data is loaded from the source.
*/
export class ModelRetrievedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is persisted to the source.
*/
export class ModelSavedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is persisted to the source.
*/
export class ModelSavingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model's data is updated.
*/
export class ModelUpdatedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model's data is updated.
*/
export class ModelUpdatingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,21 @@
import {ModelCreatedEvent} from './ModelCreatedEvent'
import {ModelUpdatingEvent} from './ModelUpdatingEvent'
import {ModelCreatingEvent} from './ModelCreatingEvent'
import {ModelSavedEvent} from './ModelSavedEvent'
import {ModelDeletedEvent} from './ModelDeletedEvent'
import {ModelDeletingEvent} from './ModelDeletingEvent'
import {ModelRetrievedEvent} from './ModelRetrievedEvent'
import {ModelUpdatedEvent} from './ModelUpdatedEvent'
import {ModelEvent} from './ModelEvent'
export const ModelEvents = {
ModelCreatedEvent,
ModelCreatingEvent,
ModelDeletedEvent,
ModelDeletingEvent,
ModelEvent,
ModelRetrievedEvent,
ModelSavedEvent,
ModelUpdatedEvent,
ModelUpdatingEvent,
}

14
src/orm/schema/Schema.ts Normal file
View File

@@ -0,0 +1,14 @@
import {Connection} from '../connection/Connection'
import {Awaitable} from '../../util'
export abstract class Schema {
constructor(
protected readonly connection: Connection,
) { }
public abstract hasTable(name: string): Awaitable<boolean>
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
}

View File

@@ -0,0 +1,109 @@
import {Pipe} from '../../util'
export abstract class SchemaBuilderBase {
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
protected shouldRenameTo?: string
constructor(
protected readonly name: string,
) { }
public drop(): this {
this.shouldDrop = 'yes'
return this
}
public dropIfExists(): this {
this.shouldDrop = 'exists'
return this
}
public rename(to: string): this {
this.shouldRenameTo = to
return this
}
pipe(): Pipe<this> {
return Pipe.wrap<this>(this)
}
}
export class ColumnBuilder extends SchemaBuilderBase {
}
export class IndexBuilder extends SchemaBuilderBase {
protected fields: Set<string> = new Set<string>()
protected removedFields: Set<string> = new Set<string>()
protected shouldBeUnique = false
protected shouldBePrimary = false
protected field(name: string): this {
this.fields.add(name)
return this
}
protected removeField(name: string): this {
this.removedFields.add(name)
this.fields.delete(name)
return this
}
primary(): this {
this.shouldBePrimary = true
return this
}
unique(): this {
this.shouldBeUnique = true
return this
}
}
export class TableBuilder extends SchemaBuilderBase {
protected columns: {[key: string]: ColumnBuilder} = {}
protected indexes: {[key: string]: IndexBuilder} = {}
public dropColumn(name: string): this {
this.column(name).drop()
return this
}
public renameColumn(from: string, to: string): this {
this.column(from).rename(to)
return this
}
public dropIndex(name: string): this {
this.index(name).drop()
return this
}
public renameIndex(from: string, to: string): this {
this.index(from).rename(to)
return this
}
public column(name: string) {
if ( !this.columns[name] ) {
this.columns[name] = new ColumnBuilder(name)
}
return this.columns[name]
}
public index(name: string) {
if ( !this.indexes[name] ) {
this.indexes[name] = new IndexBuilder(name)
}
return this.indexes[name]
}
}

View File

@@ -16,6 +16,7 @@ import {ExecuteResolvedRoutePreflightHTTPModule} from '../http/kernel/module/Exe
import {ExecuteResolvedRoutePostflightHTTPModule} from '../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule'
import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule'
import {Config} from './Config'
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
/**
* Application unit that starts the HTTP/S server, creates Request and Response objects
@@ -48,6 +49,7 @@ export class HTTPServer extends Unit {
ExecuteResolvedRoutePreflightHTTPModule.register(this.kernel)
ExecuteResolvedRoutePostflightHTTPModule.register(this.kernel)
ParseIncomingBodyHTTPModule.register(this.kernel)
InjectRequestEventBusHTTPModule.register(this.kernel)
await new Promise<void>(res => {
this.server = createServer(this.handler)

View File

@@ -7,14 +7,14 @@ import {Middleware} from '../http/routing/Middleware'
* A canonical unit that loads the middleware classes from `app/http/middlewares`.
*/
@Singleton()
export class Middlewares extends CanonicalStatic<Instantiable<Middleware>, Middleware> {
export class Middlewares extends CanonicalStatic<Middleware, Instantiable<Middleware>> {
protected appPath = ['http', 'middlewares']
protected canonicalItem = 'middleware'
protected suffix = '.middleware.js'
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Instantiable<Middleware>, Middleware>> {
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Middleware, Instantiable<Middleware>>> {
const item = await super.initCanonicalItem(definition)
if ( !(item.prototype instanceof Middleware) ) {
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.http.routing.Middleware.`)

View File

@@ -56,4 +56,11 @@ export class Routing extends Unit {
public get path(): UniversalPath {
return this.app().appPath('http', 'routes')
}
/**
* Get the collection of compiled routes.
*/
public getCompiled(): Collection<Route> {
return this.compiledRoutes
}
}

View File

@@ -1 +1,5 @@
/** Type alias for something that may or may not be wrapped in a promise. */
export type Awaitable<T> = T | Promise<T>
/** Type alias for something that may be undefined. */
export type Maybe<T> = T | undefined