├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .markdownlintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dist.js ├── jest-resolver.js ├── lerna.json ├── nestjs-libs.sublime-project ├── package-lock.json ├── package.json ├── packages ├── async-provider │ ├── CHANGELOG.md │ ├── README.md │ ├── index.ts │ └── package.json ├── auth │ ├── CHANGELOG.md │ ├── README.md │ ├── examples │ │ ├── .env.example │ │ ├── config │ │ │ └── auth.config.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ └── application-user.interface.ts │ │ └── services │ │ │ └── user.service.ts │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── adapters │ │ │ ├── index.ts │ │ │ └── keycloak.adapter.ts │ │ ├── constants.ts │ │ ├── contracts │ │ │ ├── application-user.contract.ts │ │ │ ├── auth-provider-service.contract.ts │ │ │ ├── auth-provider-user.contract.ts │ │ │ ├── index.ts │ │ │ ├── test-user-service.contract.ts │ │ │ └── user-service.contract.ts │ │ ├── controllers │ │ │ ├── index.ts │ │ │ ├── keycloack-auth.controller.ts │ │ │ └── local-auth.controller.ts │ │ ├── decorators │ │ │ ├── index.ts │ │ │ ├── local-auth.decorator.ts │ │ │ ├── public.decorator.ts │ │ │ └── user.decorator.ts │ │ ├── guards │ │ │ ├── index.ts │ │ │ ├── jwt.guard.ts │ │ │ ├── keycloak.guard.ts │ │ │ ├── local.guard.ts │ │ │ └── mock.guard.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── auth-modul-options.interface.ts │ │ │ ├── check-response.interface.ts │ │ │ ├── create-auth-provider-user.interface.ts │ │ │ ├── index.ts │ │ │ ├── jwt-payload.interface.ts │ │ │ ├── login-response.interface.ts │ │ │ └── resource-access.interface.ts │ │ ├── modules │ │ │ ├── auth.module.ts │ │ │ ├── index.ts │ │ │ ├── keycloak-auth.module.ts │ │ │ └── local-auth.module.ts │ │ ├── services │ │ │ ├── auth.service.ts │ │ │ ├── external-auth.service.ts │ │ │ ├── index.ts │ │ │ └── local-auth.service.ts │ │ ├── strategies │ │ │ ├── index.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── keycloak.strategy.ts │ │ │ ├── local.strategy.ts │ │ │ ├── mock.strategy.ts │ │ │ └── passport-mock-strategy │ │ │ │ ├── mock-user.ts │ │ │ │ └── passport-mock-strategy.ts │ │ └── types │ │ │ ├── index.ts │ │ │ └── user-id.type.ts │ └── test │ │ └── local-auth.spec.ts ├── es-cqrs-schematics │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── bin │ │ ├── dist.js │ │ └── schema2ts.js │ ├── package.json │ ├── src │ │ ├── collection.json │ │ ├── es-cqrs │ │ │ ├── aggregate │ │ │ │ ├── aggregate.factory.ts │ │ │ │ ├── aggregate.schema.json │ │ │ │ └── templates │ │ │ │ │ └── __aggregate@dasherize__ │ │ │ │ │ └── __aggregate@dasherize__.aggregate.ts │ │ │ ├── client-service │ │ │ │ ├── client-service.factory.ts │ │ │ │ ├── client-service.schema.json │ │ │ │ └── templates │ │ │ │ │ └── __graphqlType@plural__ │ │ │ │ │ └── __name@dasherize__.graphql.ts │ │ │ ├── command-handler │ │ │ │ ├── command-handler.factory.ts │ │ │ │ ├── command-handler.schema.json │ │ │ │ └── templates │ │ │ │ │ └── command-handlers │ │ │ │ │ └── __command__.handler.ts │ │ │ ├── command │ │ │ │ ├── command.factory.ts │ │ │ │ ├── command.schema.json │ │ │ │ └── templates │ │ │ │ │ └── commands │ │ │ │ │ └── __command__.command.ts │ │ │ ├── controller │ │ │ │ ├── controller.factory.ts │ │ │ │ ├── controller.schema.json │ │ │ │ └── templates │ │ │ │ │ ├── controller │ │ │ │ │ └── __aggregate@dasherize__.controller.ts │ │ │ │ │ └── dto │ │ │ │ │ └── __command@dasherize__.dto.ts │ │ │ ├── event-handler │ │ │ │ ├── event-handler.factory.ts │ │ │ │ ├── event-handler.schema.json │ │ │ │ └── templates │ │ │ │ │ └── event-handlers │ │ │ │ │ └── __event__.handler.ts │ │ │ ├── event │ │ │ │ ├── event.factory.ts │ │ │ │ ├── event.schema.json │ │ │ │ └── templates │ │ │ │ │ └── events │ │ │ │ │ └── __event__.event.ts │ │ │ ├── format.ts │ │ │ ├── index.ts │ │ │ ├── module │ │ │ │ ├── module.factory.ts │ │ │ │ ├── module.schema.json │ │ │ │ └── templates │ │ │ │ │ └── __aggregate@dasherize__ │ │ │ │ │ └── __aggregate@dasherize__.module.ts │ │ │ ├── nest6-migration │ │ │ │ ├── nest6-migration.factory.ts │ │ │ │ └── nest6-migration.schema.json │ │ │ ├── schema.json │ │ │ ├── service │ │ │ │ ├── service.factory.ts │ │ │ │ ├── service.schema.json │ │ │ │ └── templates │ │ │ │ │ └── __aggregate@dasherize__ │ │ │ │ │ └── __aggregate@dasherize__.service.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── json.schema.json │ │ └── past-participle │ │ │ ├── index.ts │ │ │ └── irregularVerbs.ts │ └── test │ │ ├── aggregate.schematic.spec.ts │ │ ├── command-handler.schematic.spec.ts │ │ ├── command.schematic.spec.ts │ │ ├── controller.schematic.spec.ts │ │ ├── event-handler.schematic.spec.ts │ │ ├── event.schematic.spec.ts │ │ ├── module.schematic.spec.ts │ │ ├── schematic-test-runner.ts │ │ └── service.schematic.spec.ts ├── es-cqrs │ ├── CHANGELOG.md │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── aggregate.ts │ │ ├── cqrs.module.ts │ │ ├── decorators │ │ │ ├── constants.ts │ │ │ ├── event-sourcable-aggregate.decorator.ts │ │ │ ├── index.ts │ │ │ └── inject-repository.decorator.ts │ │ ├── default-event.ts │ │ ├── es-cqrs.module.ts │ │ ├── event-store │ │ │ ├── aggregate-not-found.exception.ts │ │ │ ├── event-registry.ts │ │ │ ├── event-store-options.ts │ │ │ ├── event-store.exception.ts │ │ │ ├── event-store.module.ts │ │ │ ├── index.ts │ │ │ ├── inmemory-event-store.ts │ │ │ ├── replay-options.ts │ │ │ ├── replay.service.ts │ │ │ └── revision-conflict.exception.ts │ │ ├── explorer.service.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── async-provider.ts │ │ │ ├── command.ts │ │ │ ├── custom-event-options.ts │ │ │ ├── event-sourced-aggregate.ts │ │ │ ├── event-store-provider.ts │ │ │ ├── event.ts │ │ │ └── index.ts │ │ ├── rate-limited-event-bus.ts │ │ └── repository.ts │ └── test │ │ ├── aggregate.spec.ts │ │ ├── event-store │ │ ├── event-registry.spec.ts │ │ └── replay.service.spec.ts │ │ ├── inmemory-eventstore-integration.spec.ts │ │ ├── rate-limited-event-bus.spec.ts │ │ └── repository.spec.ts ├── graphql-scalar-uuid │ ├── CHANGELOG.md │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── test │ │ └── uuid-scalar.spec.ts │ └── uuid-scalar.ts ├── queue │ ├── CHANGELOG.md │ ├── README.md │ ├── examples │ │ └── config │ │ │ └── queue.config.ts │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── adapters │ │ │ ├── azure-service-bus.adapter.ts │ │ │ ├── dummy.adapter.ts │ │ │ ├── index.ts │ │ │ ├── inmemory.adapter.ts │ │ │ └── rabbitmq.adapter.ts │ │ ├── constants.ts │ │ ├── contracts │ │ │ ├── index.ts │ │ │ └── queue-service.contract.ts │ │ ├── enums │ │ │ ├── index.ts │ │ │ └── queue-type.enum.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── adapter-options │ │ │ │ ├── azure-service-bus-adapter-options.interface.ts │ │ │ │ ├── dummy-adapter-options.interface.ts │ │ │ │ ├── index.ts │ │ │ │ └── rabbitmq-adapter-options.interface.ts │ │ │ ├── index.ts │ │ │ └── queue-module-options.interface.ts │ │ ├── messages │ │ │ ├── azure-service-bus.message.ts │ │ │ ├── index.ts │ │ │ ├── inmemory.message.ts │ │ │ ├── queue.message.ts │ │ │ └── rabbitmq.message.ts │ │ └── queue.module.ts │ └── test │ │ ├── dummy.adapter.spec.ts │ │ └── inmemory.adapter.spec.ts └── storage │ ├── CHANGELOG.md │ ├── README.md │ ├── examples │ └── config │ │ └── storage.config.ts │ ├── index.ts │ ├── package.json │ ├── src │ ├── adapters │ │ ├── abstract.adapter.ts │ │ ├── azure-blob-storage.adapter.ts │ │ ├── dummy-storage.adapter.ts │ │ ├── index.ts │ │ ├── local-storage.adapter.ts │ │ └── minio-storage.adapter.ts │ ├── constants.ts │ ├── contracts │ │ ├── index.ts │ │ └── storage-driver.contract.ts │ ├── enums │ │ ├── index.ts │ │ └── storage-type.enum.ts │ ├── index.ts │ ├── interfaces │ │ ├── file-meta-data.interface.ts │ │ ├── index.ts │ │ ├── storage-adapter-options │ │ │ ├── azure-blob-storage-adapter-options.interface.ts │ │ │ ├── dummy-storage-adapter-options.interface.ts │ │ │ ├── index.ts │ │ │ ├── local-storage-adapter-options.interface.ts │ │ │ ├── minio-storage-adapter-options.interface.ts │ │ │ └── storage-adapter-options.interface.ts │ │ └── storage-module-options.interface.ts │ ├── storage.manager.ts │ └── storage.module.ts │ └── test │ ├── dummy-storage.adapter.spec.ts │ └── local-storage.adapter.spec.ts ├── tsconfig-build.json ├── tsconfig.json └── typedoc.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_size = 2 7 | end_of_line = lf 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | 16 | [Makefile] 17 | indent_style = tab 18 | 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Compiler output 2 | packages/*/dist 3 | dist 4 | 5 | # Test files 6 | *Test.js 7 | 8 | node_modules 9 | packages/*/node_modules 10 | 11 | # es-cqrs-schematics templates and generated files 12 | packages/es-cqrs-schematics/src/**/templates 13 | packages/es-cqrs-schematics/src/**/*schema.d.ts 14 | 15 | coverage 16 | 17 | !.eslintrc.js 18 | 19 | /index.js 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 10 13 | ignore: 14 | - dependency-name: "@types/node" 15 | # ignore non-LTS versions 16 | versions: ["17.x"] 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [18.x, 20.x, 22.x] 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Setup NodeJS ${{ matrix.node-version }} 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | cache: 'npm' 18 | - name: Install 19 | run: npm ci 20 | - name: Lint and Test 21 | run: | 22 | npm run lint 23 | npm run lint:md 24 | npm test 25 | 26 | publish: 27 | runs-on: ubuntu-latest 28 | if: ${{ github.ref == 'refs/heads/master' }} # publish only from master 29 | needs: [build] 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | token: ${{ secrets.GIT_PUSH_TOKEN }} 36 | - name: Configure CI user 37 | run: | 38 | git config user.email "ci@sclable.com" 39 | git config user.name "Sclable CI" 40 | - name: Setup NodeJS 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: '20' 44 | cache: 'npm' 45 | - name: Install and generate docs 46 | run: npm ci 47 | - name: Publish 48 | env: 49 | NPM_TOKEN: ${{ secrets.NPMJS_REGISTRY_TOKEN }} 50 | run: | 51 | echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} > .npmrc 52 | npx lerna publish -y 53 | - name: Generate documentation 54 | run: npx typedoc 55 | - name: Save documentation 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: docs 59 | path: docs 60 | retention-days: 1 61 | docs: 62 | runs-on: ubuntu-latest 63 | if: ${{ github.ref == 'refs/heads/master' }} 64 | needs: [publish] 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v4 68 | with: 69 | repository: ${{github.repository}}.wiki 70 | token: ${{ secrets.GIT_PUSH_TOKEN }} 71 | - name: Configure CI user 72 | run: | 73 | git config user.email "ci@sclable.com" 74 | git config user.name "Sclable CI" 75 | - name: Load documentation 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: docs 79 | path: . 80 | - name: Commit and push 81 | run: | 82 | git add . 83 | git commit -am "Update documentation" 84 | git push origin master 85 | 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | docs 4 | node_modules 5 | 6 | .vscode 7 | .idea 8 | 9 | lerna-debug.log 10 | test-bucket 11 | *.sublime-workspace 12 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged 2 | -------------------------------------------------------------------------------- /.markdownlintrc: -------------------------------------------------------------------------------- 1 | { "MD013": { "line_length": 120 } } 2 | 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | [michael.rutz@sclable.com](mailto:michael.rutz@sclable.com). All 60 | complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The project team is 62 | obligated to maintain confidentiality with regard to the reporter of an incident. 63 | Further details of specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][coc-homepage], 72 | version 1.4, available [here][coc-coc]. 73 | 74 | [coc-homepage]: https://www.contributor-covenant.org 75 | [coc-coc]: https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | Contributions are always welcome, no matter how large or small. 4 | 5 | Before contributing, please read our [code of conduct](./CODE_OF_CONDUCT.md). 6 | 7 | // TBD 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Adam Koleszar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS Library Collection 2 | 3 | This [monolithic repository][monolithic_repository] provides essential packages for developing NestJS based applications. 4 | 5 | To manage packages in this repository, we use [Lerna][lerna.js]. Consider reading the docs. 6 | 7 | ## Packages 8 | 9 | * [Async Provider](./packages/async-provider/README.md) (@sclable/nestjs-async-provider) 10 | * [Authentication](./packages/auth/README.md) (@sclable/nestjs-auth) 11 | * [ES/CQRS](./packages/es-cqrs/README.md) (@sclable/nestjs-es-cqrs) 12 | * [ES/CQRS Schematics](./packages/es-cqrs-schematics/README.md) (@sclable/nestjs-es-cqrs-schematics) 13 | * [GraphQL UUID Scalar](./packages/graphql-scalar-uuid/README.md) (@sclable/nestjs-graphql-scalar-uuid) 14 | * [Storage](./packages/storage/README.md) (@sclable/nestjs-storage) 15 | * [Queue](./packages/queue/README.md) (@sclable/nestjs-queue) 16 | 17 | ## Example project 18 | 19 | An example project is also available at [nestjs-libs-example](https://github.com/sclable/nestjs-libs-example) to show 20 | around some features and usage of the nestjs libraries. 21 | 22 | ## Contribution 23 | 24 | See our [CONTRIBUTING](CONTRIBUTING.md) guide! 25 | 26 | [monolithic_repository]: https://en.wikipedia.org/wiki/Codebase#Distinct_and_monolithic_codebases 27 | [lerna.js]: https://lernajs.io 28 | -------------------------------------------------------------------------------- /dist.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const glob = require('glob') 4 | const shelljs = require('shelljs') 5 | 6 | const packages = glob.sync('dist/*/') 7 | 8 | packages.forEach(match => { 9 | const src = match + '*' 10 | const pkg = 'packages' + match.substring(4) 11 | const dst = pkg + 'dist/' 12 | shelljs.mkdir('-p', dst) 13 | if (process.platform === 'linux') { 14 | shelljs.exec(`cp -Rvu ${src} ${dst}`) 15 | } else { 16 | shelljs.cp('-Ru', src, dst) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /jest-resolver.js: -------------------------------------------------------------------------------- 1 | // utility for handling jest unable to handle eslint in tests 2 | // from: https://github.com/typescript-eslint/typescript-eslint/blob/main/tests/jest-resolver.js 3 | 4 | const resolver = require('enhanced-resolve').create.sync({ 5 | conditionNames: ['require', 'node', 'default'], 6 | extensions: ['.js', '.json', '.node', '.ts', '.tsx'], 7 | }) 8 | 9 | module.exports = function (request, options) { 10 | // list global module that must be resolved by defaultResolver here 11 | if (['fs', 'http', 'path'].includes(request)) { 12 | return options.defaultResolver(request, options) 13 | } 14 | 15 | return resolver(options.basedir, request) 16 | } 17 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent", 6 | "command": { 7 | "publish": { 8 | "message": "chore(release): publish\n\n[skip ci]", 9 | "conventionalCommits": true, 10 | "registry": "https://registry.npmjs.org" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nestjs-libs.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | "folder_exclude_patterns": [ 6 | ".idea", 7 | ".nyc_output", 8 | "coverage", 9 | "dist", 10 | "node_modules", 11 | "test-bucket" 12 | ], 13 | "file_include_patterns": [ 14 | ".husky/*", 15 | "/*.js", 16 | "*.json", 17 | "*.ts", 18 | ".editorconfig", 19 | "*.eslint*", 20 | ".git*", 21 | "*.txt", 22 | "*.md", 23 | "*.sh", 24 | "*.yml", 25 | "*.graphql" 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/async-provider/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.0.5](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-async-provider@1.0.4...@sclable/nestjs-async-provider@1.0.5) (2024-09-11) 7 | 8 | **Note:** Version bump only for package @sclable/nestjs-async-provider 9 | 10 | ## [1.0.4](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-async-provider@1.0.3...@sclable/nestjs-async-provider@1.0.4) (2021-11-12) 11 | 12 | **Note:** Version bump only for package @sclable/nestjs-async-provider 13 | 14 | ## [1.0.3](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-async-provider@1.0.2...@sclable/nestjs-async-provider@1.0.3) (2021-11-11) 15 | 16 | **Note:** Version bump only for package @sclable/nestjs-async-provider 17 | 18 | ## [1.0.2](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-async-provider@1.0.1...@sclable/nestjs-async-provider@1.0.2) (2021-11-11) 19 | 20 | **Note:** Version bump only for package @sclable/nestjs-async-provider 21 | 22 | ## 1.0.1 (2021-11-09) 23 | 24 | **Note:** Version bump only for package @sclable/nestjs-async-provider 25 | -------------------------------------------------------------------------------- /packages/async-provider/README.md: -------------------------------------------------------------------------------- 1 | # Nestjs Async Provider 2 | 3 | Helper interface and function to create providers asynchronously for dynamic modules. 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm i @sclable/nestjs-async-provider 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```typescript 14 | import { Module, Provider } from '@nestjs/common' 15 | import { AsyncProvider, createAsyncProviders } from '@sclable/nestjs-async-provider' 16 | 17 | @Module({}) 18 | export class SomeModule { 19 | public static forRoot(options: SomeModuleOptions): DynamicModule { 20 | const optionsProvider: Provider = { 21 | provide: SOME_MODULE_OPTIONS, 22 | useValue: options, 23 | } 24 | 25 | return { 26 | module: SomeModule, 27 | imports: [...], 28 | providers: [..., optionsProvider], 29 | exports: [..., optionsProvider], 30 | } 31 | } 32 | 33 | public static forRoorAsync(options: AsyncProvider): DynamicModule { 34 | const asyncProviders = createAsyncProviders(asyncOptions, SOME_MODULE_OPTIONS) 35 | 36 | return { 37 | module: SomeModule, 38 | providers: [..., ...asyncProviders], 39 | exports: [..., ...asyncProviders], 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ## API documentation 46 | 47 | [Github Wiki](https://github.com/sclable/nestjs-libs/wiki/async-provider) 48 | -------------------------------------------------------------------------------- /packages/async-provider/index.ts: -------------------------------------------------------------------------------- 1 | import { Abstract, ModuleMetadata, Provider, Type } from '@nestjs/common/interfaces' 2 | 3 | /** For defineing a provider with `useClass` or `useExisting` the provider must implement this interface */ 4 | export interface AsyncProviderFactory { 5 | create(): T 6 | } 7 | 8 | /** ASync provider interface */ 9 | export interface AsyncProvider extends Pick { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 11 | inject?: (string | symbol | Function | Type | Abstract)[] 12 | useClass?: Type> 13 | useExisting?: Type> 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | useFactory?: (...args: any[]) => T 16 | } 17 | 18 | function createAsyncProvider(provider: AsyncProvider, token: string): Provider { 19 | if (provider.useFactory) { 20 | return { 21 | inject: provider.inject || [], 22 | provide: token, 23 | useFactory: provider.useFactory, 24 | } 25 | } 26 | 27 | let toInject 28 | if (provider.useClass === undefined) { 29 | if (provider.useExisting === undefined) { 30 | throw new Error( 31 | 'at least one of the two provider useClass and useExisting must not be undefined', 32 | ) 33 | } else { 34 | toInject = provider.useExisting 35 | } 36 | } else { 37 | toInject = provider.useClass 38 | } 39 | 40 | return { 41 | inject: [toInject], 42 | provide: token, 43 | useFactory: (providerFactory: AsyncProviderFactory) => providerFactory.create(), 44 | } 45 | } 46 | 47 | /** 48 | Creates an async provider 49 | 50 | It returns an array of providers because for the `useClass` method an additional provider need to be created 51 | 52 | @param provider an async provider 53 | @param token token for the provider (use this token with the `@Inject()` decorator) 54 | @typeParam T type of the object provided by this provider 55 | */ 56 | export function createAsyncProviders( 57 | provider: AsyncProvider, 58 | token: string, 59 | ): Provider[] { 60 | if (provider.useExisting || provider.useFactory) { 61 | return [createAsyncProvider(provider, token)] 62 | } else if (provider.useClass) { 63 | return [ 64 | createAsyncProvider(provider, token), 65 | { 66 | provide: provider.useClass, 67 | useClass: provider.useClass, 68 | }, 69 | ] 70 | } else { 71 | return [] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/async-provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-async-provider", 3 | "version": "1.0.5", 4 | "description": "ASync Provider interface for building Nestjs modules", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Sclable Business Solutions GmbH", 8 | "email": "support@sclable.com", 9 | "url": "https://sclable.com/" 10 | }, 11 | "contributors": [ 12 | "Adam Koleszar " 13 | ], 14 | "main": "dist/index.js", 15 | "types": "dist/index.d.ts", 16 | "peerDependencies": { 17 | "@nestjs/common": ">=6.11.11" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git@github.com:sclable/nestjs-libs.git" 22 | }, 23 | "publishConfig": { 24 | "registry": "https://registry.npmjs.org", 25 | "access": "public" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/auth/examples/.env.example: -------------------------------------------------------------------------------- 1 | ## AUTH_LOGLEVEL=[debug|info|warn|error] 2 | AUTH_LOGLEVEL=debug 3 | AUTH_TEST_ENDPOINT_ENABLED=false 4 | AUTH_CLIENT_ID=contakt-build 5 | AUTH_JWT_SECRET=SuperSafeSecret$$$ 6 | AUTH_JWT_EXPIRES_IN=1d 7 | 8 | AUTH_PROVIDER_URL=http://localhost:8088/auth 9 | AUTH_PROVIDER_REALM=master 10 | AUTH_PROVIDER_USER=admin 11 | AUTH_PROVIDER_PASSWORD=Pa55w0rd 12 | AUTH_PROVIDER_DB_NAME=keycloak 13 | AUTH_PROVIDER_DB_USER=KeycloakAdmin 14 | AUTH_PROVIDER_DB_PASSWORD=KCAdmin123 15 | -------------------------------------------------------------------------------- /packages/auth/examples/config/auth.config.ts: -------------------------------------------------------------------------------- 1 | // Remove linting comments in real application! 2 | // @ts-ignore 3 | import { registerAs } from '@nestjs/config' 4 | 5 | // eslint-disable-next-line import/no-default-export 6 | export default registerAs('auth', () => ({ 7 | loglevel: process.env.AUTH_LOGLEVEL || 'error', 8 | testEndpointEnabled: process.env.AUTH_TEST_ENDPOINT_ENABLED === 'true', 9 | clientId: process.env.AUTH_CLIENT_ID, 10 | jwtSecret: process.env.AUTH_JWT_SECRET, 11 | jwtExpiresIn: process.env.AUTH_JWT_EXPIRES_IN, 12 | providerUrl: process.env.AUTH_PROVIDER_URL, 13 | providerRealm: process.env.AUTH_PROVIDER_REALM, 14 | providerAdminUser: process.env.AUTH_PROVIDER_USER, 15 | providerAdminPassword: process.env.AUTH_PROVIDER_PASSWORD, 16 | })) 17 | -------------------------------------------------------------------------------- /packages/auth/examples/index.ts: -------------------------------------------------------------------------------- 1 | export { ApplicationUser } from './interfaces/application-user.interface' 2 | export { UserService } from './services/user.service' 3 | -------------------------------------------------------------------------------- /packages/auth/examples/interfaces/application-user.interface.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationUserContract } from '../../src/contracts' 2 | 3 | export interface ApplicationUser extends ApplicationUserContract { 4 | password?: string 5 | } 6 | -------------------------------------------------------------------------------- /packages/auth/examples/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { v4 as uuid } from 'uuid' 3 | 4 | import { AuthProviderUserContract, UserServiceContract } from '../../src/contracts' 5 | import { UserID } from '../../src/types' 6 | import { ApplicationUser } from '../interfaces/application-user.interface' 7 | 8 | @Injectable() 9 | export class UserService implements UserServiceContract { 10 | private users: ApplicationUser[] = [ 11 | { 12 | id: uuid(), 13 | username: 'tifa', 14 | password: 'none', 15 | firstName: 'Tifa', 16 | lastName: 'Lockhart', 17 | }, 18 | { 19 | id: uuid(), 20 | username: 'lightning', 21 | password: 'none', 22 | firstName: 'Claire', 23 | lastName: 'Farron', 24 | }, 25 | ] 26 | 27 | public getOneById(id: string): ApplicationUser | null { 28 | return this.users.find(user => user.id === id) || null 29 | } 30 | 31 | public getOneByExternalId(id: string): ApplicationUser | null { 32 | return this.users.find(user => user.externalId === id) || null 33 | } 34 | 35 | public getOneByUsernameAndPassword( 36 | username: string, 37 | password: string, 38 | ): ApplicationUser | null { 39 | return ( 40 | this.users.find(user => user.username === username && user.password === password) || null 41 | ) 42 | } 43 | 44 | public createFromExternalUserData(userData: AuthProviderUserContract): UserID { 45 | const newUser: ApplicationUser = { ...userData, id: uuid() } 46 | this.users.push(newUser) 47 | 48 | return newUser.id 49 | } 50 | 51 | public updateFromExternalUserData(userData: AuthProviderUserContract): UserID { 52 | if (!userData.externalId) { 53 | throw new Error('External ID is not present in user data.') 54 | } 55 | const user = this.getOneByExternalId(userData.externalId.toString()) 56 | if (!user) { 57 | throw new Error('User is not found.') 58 | } 59 | 60 | user.firstName = userData.firstName 61 | user.lastName = userData.lastName 62 | user.username = userData.username 63 | user.email = userData.email 64 | 65 | return user.id 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src' 2 | export * from './examples' 3 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-auth", 3 | "version": "1.2.7", 4 | "description": "NestJS Authentication Package", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Sclable Business Solutions GmbH", 8 | "email": "support@sclable.com", 9 | "url": "https://sclable.com/" 10 | }, 11 | "main": "dist/index.js", 12 | "scripts": { 13 | "build": "rimraf dist && tsc -p tsconfig.build.json" 14 | }, 15 | "dependencies": { 16 | "@sclable/nestjs-async-provider": "^1.0.5", 17 | "keycloak-admin": "^1.14.22", 18 | "passport": "^0.7.0", 19 | "passport-jwt": "^4.0.0", 20 | "passport-keycloak-bearer": "^2.4.0", 21 | "passport-local": "^1.0.0", 22 | "reflect-metadata": "^0.2.2", 23 | "uuid": "^10.0.0" 24 | }, 25 | "peerDependencies": { 26 | "@nestjs/common": ">=6.11.11", 27 | "@nestjs/jwt": ">=6.1.2", 28 | "@nestjs/passport": ">=6.2.0" 29 | }, 30 | "contributors": [ 31 | "Norbert Lehotzky ", 32 | "Adam Koleszar " 33 | ], 34 | "types": "dist/index.d.ts", 35 | "repository": { 36 | "type": "git", 37 | "url": "git@github.com:sclable/nestjs-libs.git" 38 | }, 39 | "publishConfig": { 40 | "registry": "https://registry.npmjs.org", 41 | "access": "public" 42 | }, 43 | "gitHead": "e4864ea0c0b3d5a45e983c64e5fdd22ad69de945" 44 | } 45 | -------------------------------------------------------------------------------- /packages/auth/src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { KeycloakAdapter } from './keycloak.adapter' 2 | -------------------------------------------------------------------------------- /packages/auth/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_MODULE_OPTIONS = 'AUTH_MODULE_OPTIONS' 2 | export const AUTH_PROVIDER_SERVICE = 'AUTH_PROVIDER_SERVICE' 3 | export const USER_SERVICE = 'USER_SERVICE' 4 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/application-user.contract.ts: -------------------------------------------------------------------------------- 1 | import { ResourceAccess } from '../interfaces' 2 | import { UserID } from '../types' 3 | 4 | export interface ApplicationUserContract { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | [key: string]: any 7 | id: UserID 8 | externalId?: UserID 9 | email?: string 10 | username?: string 11 | firstName?: string 12 | lastName?: string 13 | resourceAccess?: ResourceAccess 14 | } 15 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/auth-provider-service.contract.ts: -------------------------------------------------------------------------------- 1 | import { AuthProviderUserContract } from '../contracts' 2 | import { CreateAuthProviderUser } from '../interfaces' 3 | import { UserID } from '../types' 4 | 5 | export interface AuthProviderServiceContract { 6 | createUsers(users: CreateAuthProviderUser[]): Promise 7 | getUserById(id: UserID): Promise 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/auth-provider-user.contract.ts: -------------------------------------------------------------------------------- 1 | import { UserID } from '../types' 2 | 3 | export interface AuthProviderUserContract { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | [key: string]: any 6 | externalId: UserID 7 | email: string 8 | username: string 9 | firstName?: string 10 | lastName?: string 11 | } 12 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/index.ts: -------------------------------------------------------------------------------- 1 | export { ApplicationUserContract } from './application-user.contract' 2 | export { AuthProviderServiceContract } from './auth-provider-service.contract' 3 | export { AuthProviderUserContract } from './auth-provider-user.contract' 4 | export { TestUserServiceContract } from './test-user-service.contract' 5 | export { UserServiceContract } from './user-service.contract' 6 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/test-user-service.contract.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationUserContract } from './application-user.contract' 2 | import { UserServiceContract } from './user-service.contract' 3 | 4 | export interface TestUserServiceContract 5 | extends UserServiceContract { 6 | getTestUser(): UserType | Promise 7 | } 8 | -------------------------------------------------------------------------------- /packages/auth/src/contracts/user-service.contract.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationUserContract, AuthProviderUserContract } from '../contracts' 2 | import { UserID } from '../types' 3 | 4 | export interface UserServiceContract { 5 | getOneById(userId: UserID): UserType | Promise | null 6 | getOneByExternalId(externalId: UserID): UserType | Promise | null 7 | getOneByUsernameAndPassword( 8 | username: string, 9 | password: string, 10 | ): UserType | Promise | null 11 | createFromExternalUserData(userData: AuthProviderUserContract): UserID | Promise 12 | updateFromExternalUserData(userData: AuthProviderUserContract): UserID | Promise 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { LocalAuthController } from './local-auth.controller' 2 | export { KeycloakAuthController } from './keycloack-auth.controller' 3 | -------------------------------------------------------------------------------- /packages/auth/src/controllers/keycloack-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | ForbiddenException, 4 | Get, 5 | HttpStatus, 6 | Inject, 7 | Post, 8 | Request, 9 | Response, 10 | UseGuards, 11 | } from '@nestjs/common' 12 | 13 | import { AUTH_MODULE_OPTIONS } from '../constants' 14 | import { ApplicationUserContract } from '../contracts' 15 | import { KeycloakGuard } from '../guards' 16 | import { AuthModuleOptions, CheckResponse } from '../interfaces' 17 | import { ExternalAuthService } from '../services' 18 | 19 | @Controller('auth') 20 | export class KeycloakAuthController { 21 | public constructor( 22 | @Inject(AUTH_MODULE_OPTIONS) private readonly authModuleOptions: AuthModuleOptions, 23 | @Inject(ExternalAuthService) private readonly authService: ExternalAuthService, 24 | ) {} 25 | 26 | @UseGuards(KeycloakGuard) 27 | @Post('logout') 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 29 | public logout(@Request() request: any, @Response() response: any): void { 30 | this.authService.addToBlacklist( 31 | this.authService.decodeAuthorizationHeaderToken(request.headers.authorization), 32 | ) 33 | 34 | response.status(HttpStatus.NO_CONTENT).send() 35 | } 36 | 37 | @UseGuards(KeycloakGuard) 38 | @Get('check') 39 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 40 | public async check(@Request() request: any): Promise> { 41 | if (!this.authModuleOptions.config.testEndpointEnabled) { 42 | throw new ForbiddenException() 43 | } 44 | 45 | return { 46 | ctxUser: request.user, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/auth/src/controllers/local-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | ForbiddenException, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Inject, 8 | Post, 9 | Request, 10 | Response, 11 | UseGuards, 12 | } from '@nestjs/common' 13 | 14 | import { AUTH_MODULE_OPTIONS } from '../constants' 15 | import { ApplicationUserContract } from '../contracts' 16 | import { LocalAuth } from '../decorators' 17 | import { JwtGuard } from '../guards' 18 | import { AuthModuleOptions, CheckResponse, LoginResponse } from '../interfaces' 19 | import { LocalAuthService } from '../services' 20 | 21 | @Controller('auth') 22 | export class LocalAuthController { 23 | public constructor( 24 | @Inject(AUTH_MODULE_OPTIONS) private readonly authModuleOptions: AuthModuleOptions, 25 | @Inject(LocalAuthService) private readonly authService: LocalAuthService, 26 | ) {} 27 | 28 | @LocalAuth() 29 | @UseGuards(JwtGuard) 30 | @Post('login') 31 | @HttpCode(200) 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 33 | public async login(@Request() request: any): Promise { 34 | return { 35 | accessToken: await this.authService.getAccessToken(request.user), 36 | } 37 | } 38 | 39 | @UseGuards(JwtGuard) 40 | @Post('logout') 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 42 | public logout(@Request() request: any, @Response() response: any): void { 43 | this.authService.addToBlacklist( 44 | this.authService.decodeAuthorizationHeaderToken(request.headers.authorization), 45 | ) 46 | 47 | response.status(HttpStatus.NO_CONTENT).send() 48 | } 49 | 50 | @UseGuards(JwtGuard) 51 | @Get('check') 52 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types 53 | public async check(@Request() request: any): Promise> { 54 | if (!this.authModuleOptions.config.testEndpointEnabled) { 55 | throw new ForbiddenException() 56 | } 57 | 58 | return { 59 | ctxUser: request.user, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/auth/src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local-auth.decorator' 2 | export * from './public.decorator' 3 | export * from './user.decorator' 4 | -------------------------------------------------------------------------------- /packages/auth/src/decorators/local-auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { CustomDecorator, SetMetadata } from '@nestjs/common' 2 | 3 | export const IS_LOCAL_AUTH = 'isLocalAuth' 4 | export const LocalAuth = (): CustomDecorator => SetMetadata(IS_LOCAL_AUTH, true) 5 | -------------------------------------------------------------------------------- /packages/auth/src/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { CustomDecorator, SetMetadata } from '@nestjs/common' 2 | 3 | export const IS_PUBLIC_ENDPOINT = 'isPublicEndpoint' 4 | export const Public = (): CustomDecorator => SetMetadata(IS_PUBLIC_ENDPOINT, true) 5 | -------------------------------------------------------------------------------- /packages/auth/src/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator } from '@nestjs/common' 2 | 3 | export const RequestUser = createParamDecorator((_, req) => { 4 | if (Array.isArray(req)) { 5 | const [ 6 | , 7 | , 8 | { 9 | req: { user }, 10 | }, 11 | ] = req 12 | 13 | return user 14 | } 15 | 16 | return req.user 17 | }) 18 | -------------------------------------------------------------------------------- /packages/auth/src/guards/index.ts: -------------------------------------------------------------------------------- 1 | export { LocalGuard } from './local.guard' 2 | export { JwtGuard } from './jwt.guard' 3 | export { KeycloakGuard } from './keycloak.guard' 4 | export { MockGuard } from './mock.guard' 5 | -------------------------------------------------------------------------------- /packages/auth/src/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Inject } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { AuthGuard } from '@nestjs/passport' 4 | import { Observable } from 'rxjs' 5 | 6 | import { IS_LOCAL_AUTH, IS_PUBLIC_ENDPOINT } from '../decorators' 7 | import { LocalGuard } from './local.guard' 8 | 9 | export class JwtGuard extends AuthGuard('jwt') { 10 | public constructor( 11 | @Inject(Reflector) private readonly reflector: Reflector, 12 | @Inject(LocalGuard) private readonly localGuard: LocalGuard, 13 | ) { 14 | super() 15 | } 16 | public canActivate( 17 | context: ExecutionContext, 18 | ): boolean | Promise | Observable { 19 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_ENDPOINT, [ 20 | context.getHandler(), 21 | context.getClass(), 22 | ]) 23 | if (isPublic) { 24 | return true 25 | } 26 | const isLocalAuth = this.reflector.getAllAndOverride(IS_LOCAL_AUTH, [ 27 | context.getHandler(), 28 | context.getClass(), 29 | ]) 30 | if (isLocalAuth) { 31 | return this.localGuard.canActivate(context) 32 | } 33 | 34 | return super.canActivate(context) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/auth/src/guards/keycloak.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Inject } from '@nestjs/common' 2 | import { Reflector } from '@nestjs/core' 3 | import { AuthGuard } from '@nestjs/passport' 4 | import { Observable } from 'rxjs' 5 | 6 | import { IS_PUBLIC_ENDPOINT } from '../decorators' 7 | 8 | export class KeycloakGuard extends AuthGuard('keycloak') { 9 | public constructor(@Inject(Reflector) private readonly reflector: Reflector) { 10 | super() 11 | } 12 | public canActivate( 13 | context: ExecutionContext, 14 | ): boolean | Promise | Observable { 15 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_ENDPOINT, [ 16 | context.getHandler(), 17 | context.getClass(), 18 | ]) 19 | if (isPublic) { 20 | return true 21 | } 22 | 23 | return super.canActivate(context) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/auth/src/guards/local.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { Observable } from 'rxjs' 4 | 5 | export class LocalGuard extends AuthGuard('local') { 6 | public canActivate( 7 | context: ExecutionContext, 8 | ): boolean | Promise | Observable { 9 | return super.canActivate(context) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/auth/src/guards/mock.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common' 2 | import { AuthGuard } from '@nestjs/passport' 3 | import { Observable } from 'rxjs' 4 | 5 | export class MockGuard extends AuthGuard('mock') { 6 | public canActivate( 7 | context: ExecutionContext, 8 | ): boolean | Promise | Observable { 9 | return super.canActivate(context) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/auth/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ApplicationUserContract, 3 | AuthProviderUserContract, 4 | TestUserServiceContract, 5 | UserServiceContract, 6 | } from './contracts' 7 | export { LocalAuth, Public, RequestUser } from './decorators' 8 | export { AuthConfig, ResourceAccess } from './interfaces' 9 | export { KeycloakAuthModule, LocalAuthModule } from './modules' 10 | export { JwtGuard, KeycloakGuard, LocalGuard, MockGuard } from './guards' 11 | export { ExternalAuthService, LocalAuthService } from './services' 12 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/auth-modul-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationUserContract, UserServiceContract } from '../contracts' 2 | 3 | export interface AuthConfig { 4 | loglevel?: string 5 | testEndpointEnabled?: boolean 6 | providerUrl?: string 7 | providerRealm?: string 8 | providerAdminUser?: string 9 | providerAdminPassword?: string 10 | jwtSecret?: string 11 | jwtExpiresIn?: string 12 | } 13 | 14 | export interface AuthModuleOptions { 15 | config: AuthConfig 16 | userService: UserServiceContract 17 | } 18 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/check-response.interface.ts: -------------------------------------------------------------------------------- 1 | export interface CheckResponse { 2 | ctxUser: UserType 3 | } 4 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/create-auth-provider-user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface CreateAuthProviderUser { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | [key: string]: any 4 | email: string 5 | username: string 6 | firstName?: string 7 | lastName?: string 8 | } 9 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthConfig, AuthModuleOptions } from './auth-modul-options.interface' 2 | export { CheckResponse } from './check-response.interface' 3 | export { CreateAuthProviderUser } from './create-auth-provider-user.interface' 4 | export { LoginResponse } from './login-response.interface' 5 | export { JwtPayload } from './jwt-payload.interface' 6 | export { ResourceAccess } from './resource-access.interface' 7 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | import { ResourceAccess } from './resource-access.interface' 2 | 3 | export interface JwtPayload { 4 | sub?: string 5 | jti?: string 6 | exp?: number 7 | name?: string 8 | given_name?: string 9 | family_name?: string 10 | preferred_username?: string 11 | email?: string 12 | resource_access?: ResourceAccess 13 | } 14 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/login-response.interface.ts: -------------------------------------------------------------------------------- 1 | export interface LoginResponse { 2 | accessToken: string 3 | } 4 | -------------------------------------------------------------------------------- /packages/auth/src/interfaces/resource-access.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ResourceAccess { 2 | [key: string]: { 3 | roles: string[] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/auth/src/modules/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common' 2 | 3 | import { AUTH_MODULE_OPTIONS, USER_SERVICE } from '../constants' 4 | import { ApplicationUserContract, UserServiceContract } from '../contracts' 5 | import { AuthModuleOptions } from '../interfaces' 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 8 | export abstract class AuthModule { 9 | protected static getUserServiceProvider(): Provider< 10 | UserServiceContract 11 | > { 12 | return { 13 | inject: [AUTH_MODULE_OPTIONS], 14 | provide: USER_SERVICE, 15 | useFactory: (options: AuthModuleOptions) => options.userService, 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/auth/src/modules/index.ts: -------------------------------------------------------------------------------- 1 | export { KeycloakAuthModule } from './keycloak-auth.module' 2 | export { LocalAuthModule } from './local-auth.module' 3 | -------------------------------------------------------------------------------- /packages/auth/src/modules/keycloak-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Logger, Module, Provider } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { PassportModule } from '@nestjs/passport' 4 | import { AsyncProvider, createAsyncProviders } from '@sclable/nestjs-async-provider' 5 | 6 | import { KeycloakAdapter } from '../adapters' 7 | import { AUTH_MODULE_OPTIONS, AUTH_PROVIDER_SERVICE } from '../constants' 8 | import { AuthProviderServiceContract } from '../contracts' 9 | import { KeycloakAuthController } from '../controllers' 10 | import { AuthModuleOptions } from '../interfaces' 11 | import { ExternalAuthService } from '../services' 12 | import { KeycloakStrategy, MockStrategy } from '../strategies' 13 | import { AuthModule } from './auth.module' 14 | 15 | @Global() 16 | @Module({}) 17 | export class KeycloakAuthModule extends AuthModule { 18 | public static forRootAsync( 19 | asyncOptions: AsyncProvider, 20 | provideControllers: boolean = true, 21 | ): DynamicModule { 22 | const authProviderService: Provider = { 23 | provide: AUTH_PROVIDER_SERVICE, 24 | useClass: KeycloakAdapter, 25 | } 26 | 27 | const controllers = provideControllers ? [KeycloakAuthController] : [] 28 | const asyncProviders = createAsyncProviders(asyncOptions, AUTH_MODULE_OPTIONS) 29 | 30 | return { 31 | module: KeycloakAuthModule, 32 | imports: [PassportModule, JwtModule.register({}), ...(asyncOptions.imports || [])], 33 | providers: [ 34 | Logger, 35 | ExternalAuthService, 36 | KeycloakStrategy, 37 | MockStrategy, 38 | authProviderService, 39 | this.getUserServiceProvider(), 40 | ...asyncProviders, 41 | ], 42 | exports: [...asyncProviders], 43 | controllers, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/auth/src/modules/local-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Logger, Module } from '@nestjs/common' 2 | import { JwtModule } from '@nestjs/jwt' 3 | import { PassportModule } from '@nestjs/passport' 4 | import { AsyncProvider, createAsyncProviders } from '@sclable/nestjs-async-provider' 5 | 6 | import { AUTH_MODULE_OPTIONS } from '../constants' 7 | import { LocalAuthController } from '../controllers' 8 | import { LocalGuard } from '../guards' 9 | import { AuthModuleOptions } from '../interfaces' 10 | import { LocalAuthService } from '../services' 11 | import { JwtStrategy, LocalStrategy, MockStrategy } from '../strategies' 12 | import { AuthModule } from './auth.module' 13 | 14 | @Global() 15 | @Module({}) 16 | export class LocalAuthModule extends AuthModule { 17 | public static forRootAsync( 18 | asyncOptions: AsyncProvider, 19 | provideControllers: boolean = true, 20 | ): DynamicModule { 21 | const controllers = provideControllers ? [LocalAuthController] : [] 22 | const asyncProviders = createAsyncProviders(asyncOptions, AUTH_MODULE_OPTIONS) 23 | 24 | return { 25 | module: LocalAuthModule, 26 | imports: [ 27 | PassportModule, 28 | JwtModule.registerAsync({ 29 | inject: [AUTH_MODULE_OPTIONS], 30 | useFactory: (options: AuthModuleOptions) => ({ 31 | secret: options.config.jwtSecret, 32 | signOptions: { expiresIn: options.config.jwtExpiresIn }, 33 | }), 34 | }), 35 | ...(asyncOptions.imports || []), 36 | ], 37 | providers: [ 38 | Logger, 39 | LocalAuthService, 40 | LocalGuard, 41 | LocalStrategy, 42 | JwtStrategy, 43 | MockStrategy, 44 | this.getUserServiceProvider(), 45 | ...asyncProviders, 46 | ], 47 | exports: [...asyncProviders], 48 | controllers, 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/auth/src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | 4 | import { ApplicationUserContract } from '../contracts' 5 | import { JwtPayload } from '../interfaces' 6 | 7 | export abstract class AuthService { 8 | private blackList: JwtPayload[] = [] 9 | 10 | protected constructor(@Inject(JwtService) protected readonly jwtService: JwtService) {} 11 | 12 | protected static userDataChanged(user: ApplicationUserContract, token: JwtPayload): boolean { 13 | return ( 14 | user.firstName !== token.given_name || 15 | user.lastName !== token.family_name || 16 | user.username !== token.preferred_username || 17 | user.email !== token.email 18 | ) 19 | } 20 | 21 | public decodeAuthorizationHeaderToken(headerToken: string): JwtPayload { 22 | return this.jwtService.decode(headerToken.split(' ')[1]) as JwtPayload 23 | } 24 | 25 | public isValid(jwtPayload: JwtPayload): boolean { 26 | return ( 27 | !this.isBlackListed(jwtPayload) && 28 | !!jwtPayload.exp && 29 | !!jwtPayload.sub && 30 | jwtPayload.exp > Date.now() / 1000 31 | ) 32 | } 33 | 34 | public isBlackListed(jwtPayload: JwtPayload): boolean { 35 | return !!this.blackList.find(({ jti }) => jti === jwtPayload.jti) 36 | } 37 | 38 | public addToBlacklist(jwtPayload: JwtPayload): void { 39 | this.cleanupBlacklist() 40 | 41 | this.blackList.push({ 42 | jti: jwtPayload.jti, 43 | exp: jwtPayload.exp, 44 | }) 45 | } 46 | 47 | private cleanupBlacklist(): void { 48 | this.blackList = this.blackList.filter(({ exp }) => exp && exp > Date.now() / 1000) 49 | } 50 | 51 | public abstract getApplicationUser(token: JwtPayload): Promise 52 | } 53 | -------------------------------------------------------------------------------- /packages/auth/src/services/external-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | 4 | import { AUTH_PROVIDER_SERVICE, USER_SERVICE } from '../constants' 5 | import { 6 | ApplicationUserContract, 7 | AuthProviderServiceContract, 8 | UserServiceContract, 9 | } from '../contracts' 10 | import { CreateAuthProviderUser, JwtPayload } from '../interfaces' 11 | import { UserID } from '../types' 12 | import { AuthService } from './auth.service' 13 | 14 | @Injectable() 15 | export class ExternalAuthService< 16 | UserType extends ApplicationUserContract, 17 | > extends AuthService { 18 | private updateLock: string[] = [] 19 | private readonly logger: Logger = new Logger(AuthService.name) 20 | 21 | public constructor( 22 | @Inject(AUTH_PROVIDER_SERVICE) 23 | private readonly authProviderService: AuthProviderServiceContract, 24 | @Inject(USER_SERVICE) private readonly userService: UserServiceContract, 25 | @Inject(JwtService) protected readonly jwtService: JwtService, 26 | ) { 27 | super(jwtService) 28 | } 29 | 30 | public async getApplicationUser( 31 | token: JwtPayload, 32 | createIfNotExists: boolean = true, 33 | updateIfChanged: boolean = true, 34 | ): Promise { 35 | const externalId = token.sub 36 | if (!externalId) { 37 | return null 38 | } 39 | 40 | let user: UserType | null = await this.userService.getOneByExternalId(externalId) 41 | 42 | if (!user && createIfNotExists) { 43 | const userId = await this.createApplicationUser(externalId) 44 | 45 | user = userId ? await this.userService.getOneById(userId) : null 46 | } else if ( 47 | !!user && 48 | updateIfChanged && 49 | AuthService.userDataChanged(user, token) && 50 | this.updateLock.indexOf(user.id.toString()) < 0 51 | ) { 52 | const userId = await this.updateApplicationUser(externalId, user) 53 | 54 | user = userId ? await this.userService.getOneById(userId) : null 55 | } 56 | 57 | if (user) { 58 | user.resourceAccess = token.resource_access 59 | } 60 | 61 | return user 62 | } 63 | 64 | public async createAuthUser(users: CreateAuthProviderUser[]): Promise { 65 | return this.authProviderService.createUsers(users) 66 | } 67 | 68 | private async createApplicationUser(externalId: UserID): Promise { 69 | let userId: string | number | UserID | null = null 70 | try { 71 | const userData = await this.authProviderService.getUserById(externalId) 72 | if (!userData) { 73 | return null 74 | } 75 | 76 | userId = await this.userService.createFromExternalUserData(userData) 77 | } catch (error) { 78 | this.logger.warn(`Application user cannot be created (external ID: ${externalId})`) 79 | this.logger.debug(error) 80 | 81 | return null 82 | } 83 | 84 | this.logger.debug(`New application user created (ID: ${userId})`) 85 | 86 | return userId 87 | } 88 | 89 | private async updateApplicationUser( 90 | externalId: UserID, 91 | user: UserType, 92 | ): Promise { 93 | this.updateLock.push(user.id.toString()) 94 | const userData = await this.authProviderService.getUserById(externalId) 95 | if (!userData) { 96 | return null 97 | } 98 | const userId = await this.userService.updateFromExternalUserData({ 99 | ...userData, 100 | id: user.id, 101 | }) 102 | this.updateLock.splice(this.updateLock.indexOf(user.id.toString()), 1) 103 | 104 | this.logger.debug(`Application user updated (ID: ${user.id})`) 105 | 106 | return userId 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages/auth/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { LocalAuthService } from './local-auth.service' 2 | export { ExternalAuthService } from './external-auth.service' 3 | -------------------------------------------------------------------------------- /packages/auth/src/services/local-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common' 2 | import { JwtService } from '@nestjs/jwt' 3 | 4 | import { USER_SERVICE } from '../constants' 5 | import { ApplicationUserContract, UserServiceContract } from '../contracts' 6 | import { JwtPayload } from '../interfaces' 7 | import { AuthService } from './auth.service' 8 | 9 | export class LocalAuthService< 10 | UserType extends ApplicationUserContract, 11 | > extends AuthService { 12 | public constructor( 13 | @Inject(USER_SERVICE) private readonly userService: UserServiceContract, 14 | @Inject(JwtService) protected readonly jwtService: JwtService, 15 | ) { 16 | super(jwtService) 17 | } 18 | 19 | public async validateUser(username: string, password: string): Promise { 20 | return this.userService.getOneByUsernameAndPassword(username, password) 21 | } 22 | 23 | public async getAccessToken(user: ApplicationUserContract): Promise { 24 | const payload = { 25 | sub: user.id, 26 | preferred_username: user.username, 27 | email: user.email, 28 | given_name: user.firstName, 29 | family_name: user.lastName, 30 | } 31 | 32 | return this.jwtService.sign(payload) 33 | } 34 | 35 | public async getApplicationUser(token: JwtPayload): Promise { 36 | return token.sub ? this.userService.getOneById(token.sub) : null 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export { LocalStrategy } from './local.strategy' 2 | export { JwtStrategy } from './jwt.strategy' 3 | export { KeycloakStrategy } from './keycloak.strategy' 4 | export { MockStrategy } from './mock.strategy' 5 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { ExtractJwt, Strategy } from 'passport-jwt' 4 | 5 | import { AUTH_MODULE_OPTIONS } from '../constants' 6 | import { ApplicationUserContract } from '../contracts' 7 | import { AuthModuleOptions, JwtPayload } from '../interfaces' 8 | import { LocalAuthService } from '../services' 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy( 12 | Strategy, 13 | ) { 14 | public constructor( 15 | @Inject(AUTH_MODULE_OPTIONS) authModuleOptions: AuthModuleOptions, 16 | @Inject(LocalAuthService) private readonly authService: LocalAuthService, 17 | ) { 18 | super({ 19 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 20 | ignoreExpiration: false, 21 | secretOrKey: authModuleOptions.config.jwtSecret, 22 | }) 23 | } 24 | 25 | public async validate(jwtPayload: JwtPayload): Promise { 26 | if (this.authService.isBlackListed(jwtPayload)) { 27 | throw new UnauthorizedException() 28 | } 29 | 30 | const user = await this.authService.getApplicationUser(jwtPayload) 31 | if (!user) { 32 | throw new UnauthorizedException() 33 | } 34 | 35 | return user 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/keycloak.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger, UnauthorizedException } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | // @ts-ignore 4 | import KeycloakBearerStrategy from 'passport-keycloak-bearer' 5 | 6 | import { AUTH_MODULE_OPTIONS } from '../constants' 7 | import { ApplicationUserContract } from '../contracts' 8 | import { AuthModuleOptions, JwtPayload } from '../interfaces' 9 | import { ExternalAuthService } from '../services' 10 | 11 | export class KeycloakStrategy< 12 | UserType extends ApplicationUserContract, 13 | > extends PassportStrategy(KeycloakBearerStrategy) { 14 | private readonly logger: Logger = new Logger(KeycloakStrategy.name) 15 | public constructor( 16 | @Inject(AUTH_MODULE_OPTIONS) authModuleOptions: AuthModuleOptions, 17 | @Inject(ExternalAuthService) private readonly authService: ExternalAuthService, 18 | ) { 19 | super({ 20 | realm: authModuleOptions.config.providerRealm, 21 | url: authModuleOptions.config.providerUrl, 22 | loggingLevel: authModuleOptions.config.loglevel || 'error', 23 | }) 24 | } 25 | 26 | public async validate(jwtPayload: JwtPayload): Promise { 27 | if (this.authService.isBlackListed(jwtPayload)) { 28 | throw new UnauthorizedException() 29 | } 30 | 31 | const user = await this.authService.getApplicationUser(jwtPayload) 32 | if (!user) { 33 | throw new UnauthorizedException() 34 | } 35 | 36 | this.logger.debug(`Application user successfully authenticated (ID: ${user.id})`) 37 | 38 | return user 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | import { Strategy } from 'passport-local' 4 | 5 | import { ApplicationUserContract } from '../contracts' 6 | import { LocalAuthService } from '../services' 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy( 10 | Strategy, 11 | ) { 12 | private readonly logger: Logger = new Logger(LocalStrategy.name) 13 | public constructor( 14 | @Inject(LocalAuthService) private readonly authService: LocalAuthService, 15 | ) { 16 | super() 17 | } 18 | 19 | protected async validate(username: string, password: string): Promise { 20 | const user = await this.authService.validateUser(username, password) 21 | if (!user) { 22 | throw new UnauthorizedException() 23 | } 24 | 25 | this.logger.debug(`Application user successfully authenticated (ID: ${user.id})`) 26 | 27 | return user 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/mock.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common' 2 | import { PassportStrategy } from '@nestjs/passport' 3 | 4 | import { USER_SERVICE } from '../constants' 5 | import { ApplicationUserContract, TestUserServiceContract } from '../contracts' 6 | import { PassportMockStrategy } from './passport-mock-strategy/passport-mock-strategy' 7 | 8 | @Injectable() 9 | export class MockStrategy extends PassportStrategy( 10 | PassportMockStrategy, 11 | ) { 12 | private readonly logger: Logger = new Logger(MockStrategy.name) 13 | public constructor( 14 | @Inject(USER_SERVICE) private readonly userService: TestUserServiceContract, 15 | ) { 16 | super() 17 | } 18 | 19 | protected async validate(): Promise { 20 | const user = await this.userService.getTestUser() 21 | 22 | this.logger.debug(`MOCK user successfully authenticated (ID: ${user.id})`) 23 | 24 | return user 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/auth/src/strategies/passport-mock-strategy/mock-user.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/cszatmary/passport-mock-strategy 2 | 3 | export interface User { 4 | id: string 5 | displayName?: string 6 | name: { familyName: string; givenName: string } 7 | emails: [{ value: string; type: string }] 8 | provider: string 9 | } 10 | 11 | export const mockUser: User = { 12 | id: '1234', 13 | displayName: 'Foo Bar', 14 | name: { familyName: 'Bar', givenName: 'Foo' }, 15 | emails: [{ value: 'foo@bar.com', type: 'account' }], 16 | provider: 'mock', 17 | } 18 | -------------------------------------------------------------------------------- /packages/auth/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export { UserID } from './user-id.type' 2 | -------------------------------------------------------------------------------- /packages/auth/src/types/user-id.type.ts: -------------------------------------------------------------------------------- 1 | export type UserID = string | number 2 | -------------------------------------------------------------------------------- /packages/auth/test/local-auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Module } from '@nestjs/common' 2 | import { Test as NestTest, TestingModule } from '@nestjs/testing' 3 | import request from 'supertest' 4 | 5 | import { UserService } from '../examples' 6 | import { JwtGuard, LocalAuthModule, LocalGuard } from '../src' 7 | 8 | @Module({ 9 | providers: [UserService], 10 | exports: [UserService], 11 | }) 12 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 13 | class UserModule {} 14 | 15 | describe('Local Authentication', () => { 16 | let testModule: TestingModule 17 | let app: INestApplication 18 | let testServer: ReturnType 19 | let bearerToken: string 20 | 21 | beforeAll(async () => { 22 | testModule = await NestTest.createTestingModule({ 23 | imports: [ 24 | UserModule, 25 | LocalAuthModule.forRootAsync({ 26 | imports: [UserModule], 27 | inject: [UserService], 28 | useFactory: (userService: UserService) => ({ 29 | config: { 30 | testEndpointEnabled: true, 31 | jwtSecret: 'test-secret', 32 | jwtExpiresIn: '1d', 33 | }, 34 | userService, 35 | }), 36 | }), 37 | ], 38 | }).compile() 39 | 40 | app = testModule.createNestApplication() 41 | await app.init() 42 | 43 | testServer = request(app.getHttpServer()) 44 | }) 45 | 46 | test('guards defined', () => { 47 | expect(testModule.get(LocalGuard)).toBeDefined() 48 | expect(testModule.get(JwtGuard)).toBeDefined() 49 | }) 50 | 51 | test('/auth/check unauthenticated', async () => { 52 | await testServer.get('/auth/check').expect(401) 53 | }) 54 | 55 | test('/auth/login', async () => { 56 | const response = await testServer 57 | .post('/auth/login') 58 | .send({ username: 'tifa', password: 'none' }) 59 | .expect(200) 60 | bearerToken = response.body.accessToken 61 | }) 62 | 63 | test('/auth/check', async () => { 64 | const response = await testServer 65 | .get('/auth/check') 66 | .set({ Authorization: `Bearer ${bearerToken}` }) 67 | .expect(200) 68 | expect(response.body.ctxUser).toEqual( 69 | expect.objectContaining({ 70 | username: 'tifa', 71 | firstName: 'Tifa', 72 | lastName: 'Lockhart', 73 | }), 74 | ) 75 | }) 76 | 77 | test('/auth/logout', async () => { 78 | await testServer 79 | .post('/auth/logout') 80 | .set({ Authorization: `Bearer ${bearerToken}` }) 81 | .expect(204) 82 | await testServer.get('/auth/check').expect(401) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/.gitignore: -------------------------------------------------------------------------------- 1 | **/*schema.d.ts -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/README.md: -------------------------------------------------------------------------------- 1 | # Schematics try-out and generator for the ES/CQRS library 2 | 3 | ## Usage 4 | 5 | Create a JSON file with your configuration. Check the schema: `src/es-cqrs/schema.json`. 6 | 7 | Example: new-command.json 8 | 9 | ```json 10 | { 11 | "moduleName": "main", 12 | "verb": "do", 13 | "subject": "stuff again", 14 | "parameters": [ 15 | { 16 | "name": "param1", 17 | "type": "string" 18 | }, 19 | { 20 | "name": "param2", 21 | "type": "ImportedParameter", 22 | "importPath": "imported-params" 23 | } 24 | ] 25 | } 26 | ``` 27 | 28 | Install schematics and this project: 29 | 30 | ```bash 31 | npm i -g @angular-devkit/schematics-cli 32 | npm i @sclable/es-cqrs-schematics 33 | ``` 34 | 35 | Run `schematics` in your code to create a command-event chain 36 | 37 | ```bash 38 | schematics @sclable/es-cqrs-schematics:json new-command.json all 39 | ``` 40 | 41 | There are targets other than `all` but they may need a different 42 | *JSON Schema* which can be read from the source. 43 | 44 | Example: to only create a command, you can use the upper mentioned 45 | JSON file with the `command` target or check `src/es-cqrs/command/command.schema.json` 46 | and run your JSON with the `command-standalone` target. 47 | 48 | ## Additional features 49 | 50 | ### REST-API generation 51 | 52 | Use the collection `all-rest` to create *nestjs controllers* as well. For create operation it will generate 53 | `Post` endpoints that returns the new ID, for other modification it will `Put` endpoint with `/:id` as parameter 54 | and returns nothing 55 | 56 | ### Graphql generation 57 | 58 | TBD 59 | 60 | ## Examples and recommendations 61 | 62 | ### Aggregate creation 63 | 64 | Usually there is no ID associated with a creation, only a user, so the system can assign and return a new ID. 65 | To help with this logic this schematics can generate create component with UUID generation. It will create such 66 | components if the `verb` is one of `[add, create, new, insert]` and the `subject` is the same as the `moduleName` 67 | 68 | ### Aggregate member generation 69 | 70 | If needed this schematics can generate member properties for the aggregate and will set their value from the 71 | events (otherwise handler contain just `/* no-op */` placeholder). To achieve this, set `isMember` to `true` 72 | for the parameter that needs a member created. 73 | 74 | ### Using object parameters 75 | 76 | If the mutation uses a single object parameter and not a list of primitive types, set `isExistingObject` to `true`. 77 | This will generate proper value assignments. 78 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/bin/dist.js: -------------------------------------------------------------------------------- 1 | const resolve = require('path').resolve 2 | 3 | const copy = require('fs-extra').copy 4 | const glob = require('glob-promise').promise 5 | 6 | async function run() { 7 | const schemaFiles = await glob('**/*schema.*', { 8 | ignore: ['node_modules/**/*', 'dist/**/*'], 9 | }) 10 | const templates = await glob('**/templates', { 11 | ignore: ['node_modules/**/*', 'dist/**/*'], 12 | }) 13 | try { 14 | await Promise.all(schemaFiles.map(async file => copy(file, resolve('dist', file)))) 15 | await Promise.all(templates.map(async tmpl => copy(tmpl, resolve('dist', tmpl)))) 16 | await copy('src/collection.json', 'dist/src/collection.json') 17 | } catch (err) { 18 | // eslint-disable-next-line no-console 19 | console.error(err) 20 | } 21 | } 22 | 23 | run() 24 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/bin/schema2ts.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const writeFile = require('fs-extra').writeFile 4 | const glob = require('glob-promise').promise 5 | const compileFromFile = require('json-schema-to-typescript').compileFromFile 6 | 7 | async function run() { 8 | const files = await glob('**/*schema.json', { ignore: 'node_modules/**/*' }) 9 | files.forEach(async file => { 10 | const dts = await compileFromFile(file) 11 | await writeFile( 12 | path.resolve(path.dirname(file), `${path.basename(file, 'json')}d.ts`), 13 | dts, 14 | ) 15 | }) 16 | } 17 | 18 | run() 19 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-es-cqrs-schematics", 3 | "version": "2.0.4", 4 | "description": "Schematics for the ES/CQRS package", 5 | "scripts": { 6 | "precompile": "node bin/schema2ts.js", 7 | "dist": "node bin/dist.js" 8 | }, 9 | "keywords": [ 10 | "schematics" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:sclable/nestjs-libs.git" 15 | }, 16 | "author": { 17 | "name": "Sclable Business Solutions GmbH", 18 | "email": "office@sclable.com", 19 | "url": "https://www.sclable.com/" 20 | }, 21 | "contributors": [ 22 | "Adam Koleszar " 23 | ], 24 | "publishConfig": { 25 | "registry": "https://registry.npmjs.org", 26 | "access": "public" 27 | }, 28 | "license": "MIT", 29 | "schematics": "./dist/src/collection.json", 30 | "dependencies": { 31 | "eslint": "^8.17.0", 32 | "fs-extra": "^11.0.0", 33 | "pluralize": "^8.0.0", 34 | "prettier": "2.8.8", 35 | "ts-morph": "^23.0.0" 36 | }, 37 | "peerDependencies": { 38 | "@angular-devkit/core": ">=16.0.0", 39 | "@angular-devkit/schematics": ">=16.0.0" 40 | }, 41 | "main": "dist/src/index.js", 42 | "types": "dist/index.d.ts", 43 | "files": [ 44 | "dist" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/aggregate/aggregate.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "aggregate", 4 | "title": "AggregateSchema", 5 | "type": "object", 6 | "properties": { 7 | "aggregate": { 8 | "type": "string" 9 | }, 10 | "command": { 11 | "type": "string" 12 | }, 13 | "event": { 14 | "type": "string" 15 | }, 16 | "isCreating": { 17 | "type": "boolean" 18 | }, 19 | "needsEventData": { 20 | "type": "boolean" 21 | }, 22 | "hasMembers": { 23 | "type": "boolean" 24 | }, 25 | "parameters": { 26 | "type": "array", 27 | "items": { 28 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 29 | } 30 | }, 31 | "imports": { 32 | "type": "array", 33 | "items": { 34 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 35 | } 36 | } 37 | }, 38 | "required": ["aggregate", "command", "event", "isCreating"] 39 | } 40 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/aggregate/templates/__aggregate@dasherize__/__aggregate@dasherize__.aggregate.ts: -------------------------------------------------------------------------------- 1 | import { Aggregate, EventSourcableAggregate } from '@sclable/nestjs-es-cqrs' 2 | import { v4 as uuidv4 } from 'uuid' 3 | <% for (let imp of imports) { %> 4 | import { <%= imp.imports.join(', ') %> } from '<%= imp.path %>'<% } %> 5 | import { <%= camelize(aggregate) %>Events, <%= classify(event) %> } from './events' 6 | 7 | @EventSourcableAggregate(...<%= camelize(aggregate) %>Events) 8 | export class <%= classify(aggregate) %> extends Aggregate {<% if (hasMembers) { parameters.filter(param => param.isMember).forEach(param => { %> 9 | private <%= param.name %>: <%= param.type %><% }) } %> 10 | <% if (isCreating) { %> 11 | public static <%= camelize(command) %>(<%= parameters.map(p => `${p.name}: ${p.type}`).join(', ') %><%= parameters.length > 0 ? ', ' : '' %>userId: string, id: string = uuidv4()): <%= classify(aggregate) %> { 12 | const self = new <%= classify(aggregate) %>(id, userId) 13 | this.applyEvent(<%= classify(event) %>, <% if (needsEventData) { %>{ <%= parameters.map(p => p.name).join(', ') %> }<% } else { %><%= parameters[0].name %><% } %>) 14 | return self 15 | }<% } else { %> 16 | public <%= camelize(command) %>(<%= parameters.map(p => `${p.name}: ${p.type}`).join(', ') %>): void { 17 | this.applyEvent(<%= classify(event) %>, <% if (needsEventData) { %>{ <%= parameters.map(p => p.name).join(', ') %> }<% } else { %><%= parameters[0].name %><% } %>) 18 | }<% } %> 19 | 20 | public on<%= classify(event) %>(<% if (!hasMembers) { %>_<% } %>event: <%= classify(event) %>): void {<% if (hasMembers) { parameters.filter(param => param.isMember).forEach(param => { if (param.isExistingObject) {%> 21 | this.<%= camelize(param.name) %> = event.data<% } else { %> 22 | this.<%= camelize(param.name) %> = event.data.<%= camelize(param.name) %><% } }) } else { %> 23 | /* no-op */<% } %> 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/client-service/client-service.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "client-service", 4 | "title": "ClientServiceSchema", 5 | "type": "object", 6 | "properties": { 7 | "name": { 8 | "type": "string" 9 | }, 10 | "moduleName": { 11 | "type": "string" 12 | }, 13 | "graphqlType": { 14 | "type": "string", 15 | "enum": ["query", "mutation", "subscription"] 16 | }, 17 | "graphqlFunction": { 18 | "type": "string" 19 | }, 20 | "returnType": { 21 | "type": "string" 22 | }, 23 | "readParameters": { 24 | "type": "string", 25 | "description": "Space separated list of parameters to read from graphql response" 26 | }, 27 | "parameters": { 28 | "type": "array", 29 | "items": { 30 | "$ref": "#/definitions/parameter" 31 | } 32 | }, 33 | "imports": { 34 | "type": "array", 35 | "items": { 36 | "$ref": "#/definitions/import" 37 | } 38 | } 39 | }, 40 | "required": ["name", "moduleName", "graphqlType", "graphqlFunction"], 41 | "definitions": { 42 | "parameter": { 43 | "type": "object", 44 | "properties": { 45 | "name": { 46 | "type": "string" 47 | }, 48 | "type": { 49 | "type": "string" 50 | }, 51 | "importPath": { 52 | "type": "string" 53 | } 54 | }, 55 | "required": ["name", "type"] 56 | }, 57 | "import": { 58 | "type": "object", 59 | "properties": { 60 | "path": { 61 | "type": "string" 62 | }, 63 | "imports": { 64 | "type": "array", 65 | "items": { 66 | "type": "string" 67 | } 68 | } 69 | }, 70 | "required": ["path", "imports"] 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/client-service/templates/__graphqlType@plural__/__name@dasherize__.graphql.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { <%= classify(graphqlType) %> } from 'apollo-angular' 3 | import gql from 'graphql-tag' 4 | <% for (let imp of imports) { %> 5 | import { <% for (let i of imp.imports) { %><%= i %>, <% } %>} from '<%= imp.path %>'<% } %> 6 | 7 | <% if (graphqlType === 'query') { %> 8 | export interface Response { 9 | <%= graphqlFunction %>: <%= returnType %>, 10 | } 11 | <% } %> 12 | 13 | @Injectable({ 14 | providedIn: 'root', 15 | }) 16 | export class <%= classify(name) %>GQL extends <%= classify(graphqlType) %><% if (graphqlType === 'query') { %><% } %> { 17 | document = gql` 18 | <%= graphqlType %>(<% for (let param of parameters) { %>$<%= param.name %>: <%= param.type %>,<% } %>) { 19 | <%= graphqlFunction %>(<% for (let param of parameters) { %><%= param.name %>: $<%= param.name %>,<% } %>) 20 | <% if (readParameters) { %>{<%= readParameters %>}<% } %> 21 | } 22 | ` 23 | } 24 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command-handler/command-handler.factory.ts: -------------------------------------------------------------------------------- 1 | import { Path, join, strings } from '@angular-devkit/core' 2 | import { 3 | Rule, 4 | Source, 5 | Tree, 6 | apply, 7 | chain, 8 | mergeWith, 9 | move, 10 | template, 11 | url, 12 | } from '@angular-devkit/schematics' 13 | import { Project, VariableDeclarationKind } from 'ts-morph' 14 | 15 | import { formatCodeSettings } from '../format' 16 | import { EsCqrsSchema } from '../schema' 17 | import { isCreating, mergeWithArrayString } from '../utils' 18 | import { CommandHandlerSchema } from './command-handler.schema' 19 | 20 | export function main(options: EsCqrsSchema): Rule { 21 | return chain([ 22 | standalone(transform(options)), 23 | // format(), 24 | ]) 25 | } 26 | 27 | export function standalone(options: CommandHandlerSchema): Rule { 28 | return chain([mergeWith(generate(options)), updateIndex(options)]) 29 | } 30 | 31 | function transform(options: EsCqrsSchema): CommandHandlerSchema { 32 | return { 33 | command: `${strings.dasherize(options.verb)}-${strings.dasherize(options.subject)}`, 34 | aggregate: options.moduleName, 35 | parameters: options.parameters || [], 36 | isCreating: isCreating(options), 37 | } 38 | } 39 | 40 | function generate(options: CommandHandlerSchema): Source { 41 | return apply(url('./templates'), [ 42 | template({ 43 | ...strings, 44 | ...options, 45 | }), 46 | move(join('src' as Path, strings.dasherize(options.aggregate))), 47 | ]) 48 | } 49 | 50 | function updateIndex(options: CommandHandlerSchema): Rule { 51 | return (tree: Tree) => { 52 | const indexPath = join( 53 | 'src' as Path, 54 | strings.dasherize(options.aggregate), 55 | 'command-handlers', 56 | 'index.ts', 57 | ) 58 | const indexSrc = tree.read(indexPath) 59 | const project = new Project({ tsConfigFilePath: 'tsconfig.json' }) 60 | const commandHandlersIndex = project.createSourceFile( 61 | 'command-handlers.index.ts', 62 | indexSrc ? indexSrc.toString() : '', 63 | ) 64 | 65 | const moduleSpecifier = `./${strings.dasherize(options.command)}.handler` 66 | const commandHandlerClass = `${strings.classify(options.command)}Handler` 67 | const namedImport = commandHandlersIndex.getImportDeclaration(moduleSpecifier) 68 | if (!namedImport) { 69 | commandHandlersIndex.addImportDeclaration({ 70 | moduleSpecifier, 71 | namedImports: [commandHandlerClass], 72 | }) 73 | } 74 | const exportAsArray = commandHandlersIndex.getVariableStatement('commandHandlers') 75 | if (!exportAsArray) { 76 | commandHandlersIndex.addVariableStatement({ 77 | declarationKind: VariableDeclarationKind.Const, 78 | declarations: [{ name: 'commandHandlers', initializer: `[${commandHandlerClass}]` }], 79 | isExported: true, 80 | }) 81 | } else { 82 | const array = exportAsArray.getDeclarations()[0].getInitializer() 83 | if (array) { 84 | exportAsArray 85 | .getDeclarations()[0] 86 | .setInitializer(mergeWithArrayString(array.getText(), commandHandlerClass)) 87 | } 88 | } 89 | commandHandlersIndex.formatText(formatCodeSettings) 90 | if (!tree.exists(indexPath)) { 91 | tree.create(indexPath, commandHandlersIndex.getFullText()) 92 | } else { 93 | tree.overwrite(indexPath, commandHandlersIndex.getFullText()) 94 | } 95 | 96 | return tree 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command-handler/command-handler.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "command", 4 | "title": "CommandHandlerSchema", 5 | "type": "object", 6 | "properties": { 7 | "command": { 8 | "type": "string" 9 | }, 10 | "aggregate": { 11 | "type": "string" 12 | }, 13 | "isCreating": { 14 | "type": "boolean" 15 | }, 16 | "parameters": { 17 | "type": "array", 18 | "items": { 19 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 20 | } 21 | }, 22 | "imports": { 23 | "type": "array", 24 | "items": { 25 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 26 | } 27 | } 28 | }, 29 | "required": ["command", "aggregate", "isCreating"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command-handler/templates/command-handlers/__command__.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler, InjectRepository, Repository } from '@sclable/nestjs-es-cqrs' 2 | 3 | import { <%= classify(command) %> } from '../commands' 4 | import { <%= classify(aggregate) %> } from '../<%= dasherize(aggregate) %>.aggregate' 5 | 6 | @CommandHandler(<%= classify(command) %>) 7 | export class <%= classify(command) %>Handler implements ICommandHandler<<%= classify(command) %>> { 8 | constructor(@InjectRepository(<%= classify(aggregate) %>) private readonly <%= camelize(aggregate) %>Repository: Repository<<%= classify(aggregate) %>>) {} 9 | 10 | public async execute(cmd: <%= classify(command) %>): Promise<<%= isCreating ? 'string' : 'void' %>> {<% if (isCreating) { %> 11 | const <%= camelize(aggregate) %> = <%= classify(aggregate) %>.<%= camelize(command) %>(<%= parameters.map(p => 'cmd.' + p.name).join(', ') %><% if (parameters.length > 0) { %>, <% } %>cmd.userId)<% } else { %> 12 | const <%= camelize(aggregate) %> = await this.<%= camelize(aggregate) %>Repository.find(cmd.id, cmd.userId) 13 | <%= camelize(aggregate) %>.<%= camelize(command) %>(<%= parameters.map(p => 'cmd.' + p.name).join(', ') %>)<% } %> 14 | await this.<%= camelize(aggregate) %>Repository.persist(<%= camelize(aggregate) %>)<% if (isCreating) { %> 15 | return <%= camelize(aggregate) %>.id<% } %> 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command/command.factory.ts: -------------------------------------------------------------------------------- 1 | import { Path, join, strings } from '@angular-devkit/core' 2 | import { 3 | Rule, 4 | Source, 5 | Tree, 6 | apply, 7 | chain, 8 | mergeWith, 9 | move, 10 | template, 11 | url, 12 | } from '@angular-devkit/schematics' 13 | import { Project } from 'ts-morph' 14 | 15 | import { formatCodeSettings } from '../format' 16 | import { EsCqrsSchema } from '../schema' 17 | import { getImports, isCreating } from '../utils' 18 | import { CommandSchema } from './command.schema' 19 | 20 | export function main(options: EsCqrsSchema): Rule { 21 | return chain([standalone(transform(options))]) 22 | } 23 | 24 | export function standalone(options: CommandSchema): Rule { 25 | return chain([mergeWith(generate(options)), updateIndex(options)]) 26 | } 27 | 28 | function transform(options: EsCqrsSchema): CommandSchema { 29 | return { 30 | command: `${strings.dasherize(options.verb)}-${strings.dasherize(options.subject)}`, 31 | imports: getImports(options.parameters ?? []), 32 | aggregate: options.moduleName, 33 | parameters: options.parameters, 34 | isCreating: isCreating(options), 35 | } 36 | } 37 | 38 | function generate(options: CommandSchema): Source { 39 | return apply(url('./templates'), [ 40 | template({ 41 | ...strings, 42 | ...options, 43 | }), 44 | move(join('src' as Path, strings.dasherize(options.aggregate))), 45 | ]) 46 | } 47 | 48 | function updateIndex(options: CommandSchema): Rule { 49 | return (tree: Tree) => { 50 | const indexPath = join( 51 | 'src' as Path, 52 | strings.dasherize(options.aggregate), 53 | 'commands', 54 | 'index.ts', 55 | ) 56 | const indexSrc = tree.read(indexPath) 57 | const project = new Project({ tsConfigFilePath: 'tsconfig.json' }) 58 | const commandsIndex = project.createSourceFile( 59 | 'commands.index.ts', 60 | indexSrc ? indexSrc.toString() : '', 61 | ) 62 | 63 | const moduleSpecifier = `./${options.command}.command` 64 | const namedExport = commandsIndex.getExportDeclaration(moduleSpecifier) 65 | if (!namedExport) { 66 | commandsIndex.addExportDeclaration({ 67 | moduleSpecifier, 68 | namedExports: [`${strings.classify(options.command)}`], 69 | }) 70 | } 71 | commandsIndex.formatText(formatCodeSettings) 72 | if (!tree.exists(indexPath)) { 73 | tree.create(indexPath, commandsIndex.getFullText()) 74 | } else { 75 | tree.overwrite(indexPath, commandsIndex.getFullText()) 76 | } 77 | 78 | return tree 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command/command.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "command", 4 | "title": "CommandSchema", 5 | "type": "object", 6 | "properties": { 7 | "command": { 8 | "type": "string" 9 | }, 10 | "aggregate": { 11 | "type": "string" 12 | }, 13 | "isCreating": { 14 | "type": "boolean" 15 | }, 16 | "parameters": { 17 | "type": "array", 18 | "items": { 19 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 20 | } 21 | }, 22 | "imports": { 23 | "type": "array", 24 | "items": { 25 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 26 | } 27 | } 28 | }, 29 | "required": ["command", "aggregate", "isCreating"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/command/templates/commands/__command__.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@sclable/nestjs-es-cqrs' 2 | <% for (let imp of imports) { %> 3 | import { <%= imp.imports.join(', ') %> } from '<%= imp.path %>'<% } %> 4 | 5 | export class <%= classify(command) %> implements Command { 6 | constructor(<% if (!isCreating) { %> 7 | public readonly id: string,<% } %><% for (let param of parameters) { %> 8 | public readonly <%= param.name %>: <%= param.type %>,<% } %> 9 | public readonly userId: string, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/controller/controller.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "controller", 4 | "title": "ControllerSchema", 5 | "type": "object", 6 | "properties": { 7 | "aggregate": { 8 | "type": "string" 9 | }, 10 | "command": { 11 | "type": "string" 12 | }, 13 | "httpMethod": { 14 | "type": "string", 15 | "default": "Put" 16 | }, 17 | "isCreating": { 18 | "type": "boolean" 19 | }, 20 | "needsDto": { 21 | "type": "boolean" 22 | }, 23 | "parameters": { 24 | "type": "array", 25 | "items": { 26 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 27 | } 28 | }, 29 | "imports": { 30 | "type": "array", 31 | "items": { 32 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 33 | } 34 | } 35 | }, 36 | "required": ["aggregate", "command", "isCreating", "needsDto"] 37 | } 38 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/controller/templates/controller/__aggregate@dasherize__.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, <%= httpMethod %>, Param } from '@nestjs/common' 2 | import { ApplicationUserContract, RequestUser } from '@sclable/nestjs-auth'<% if (needsDto) { %> 3 | import { <%= classify(command) %>Dto } from './dto'<% } %> 4 | import { <%= classify(aggregate) %>Service } from './<%= dasherize(aggregate) %>.service' 5 | 6 | @Controller('<%= dasherize(aggregate) %>') 7 | export class <%= classify(aggregate) %>Controller { 8 | public constructor(private readonly <%= camelize(aggregate) %>Service: <%= classify(aggregate) %>Service) {} 9 | 10 | @<%= httpMethod %>(<% if (!isCreating) { %>'/:id'<% } %>) 11 | public async <%= camelize(command) %>(<% if (!isCreating) { %> 12 | @Param('id') id: string,<% } %><% if (needsDto) { %> 13 | @Body() dto: <%= classify(command) %>Dto,<% } %> 14 | @RequestUser() user: ApplicationUserContract, 15 | ): Promise<<%= isCreating ? 'string' : 'void' %>> { 16 | return this.<%= camelize(aggregate) %>Service.<%= camelize(command) %>(<% if (!isCreating) { %>id, <% } %><%= parameters.map(p => 'dto.' + p.name).join(', ') %><%= parameters.length > 0 ? ', ': '' %>user.id) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/controller/templates/dto/__command@dasherize__.dto.ts: -------------------------------------------------------------------------------- 1 | <% for (let imp of imports) { %>import { <%= imp.imports.join(', ') %> } from '<%= imp.path %>'<% } %> 2 | 3 | export interface <%= classify(command) %>Dto {<% for (let param of parameters) { %> 4 | <%= param.name %>: <%= param.type %><% } %> 5 | } 6 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event-handler/event-handler.factory.ts: -------------------------------------------------------------------------------- 1 | import { Path, join, strings } from '@angular-devkit/core' 2 | import { 3 | Rule, 4 | Source, 5 | Tree, 6 | apply, 7 | chain, 8 | mergeWith, 9 | move, 10 | template, 11 | url, 12 | } from '@angular-devkit/schematics' 13 | import { Project, VariableDeclarationKind } from 'ts-morph' 14 | 15 | import { pastParticiple } from '../../past-participle' 16 | import { formatCodeSettings } from '../format' 17 | import { EsCqrsSchema } from '../schema' 18 | import { mergeWithArrayString } from '../utils' 19 | import { EventHandlerSchema } from './event-handler.schema' 20 | 21 | export function main(options: EsCqrsSchema): Rule { 22 | return standalone(transform(options)) 23 | } 24 | 25 | export function standalone(options: EventHandlerSchema): Rule { 26 | return chain([mergeWith(generate(options)), updateIndex(options)]) 27 | } 28 | 29 | function transform(options: EsCqrsSchema): EventHandlerSchema { 30 | return { 31 | event: `${strings.dasherize(options.subject)}-${pastParticiple(options.verb)}`, 32 | aggregate: options.moduleName, 33 | } 34 | } 35 | 36 | function generate(options: EventHandlerSchema): Source { 37 | return apply(url('./templates'), [ 38 | template({ 39 | ...strings, 40 | ...options, 41 | }), 42 | move(join('src' as Path, strings.dasherize(options.aggregate))), 43 | ]) 44 | } 45 | 46 | function updateIndex(options: EventHandlerSchema): Rule { 47 | return (tree: Tree) => { 48 | const indexPath = join( 49 | 'src' as Path, 50 | strings.dasherize(options.aggregate), 51 | 'event-handlers', 52 | 'index.ts', 53 | ) 54 | const indexSrc = tree.read(indexPath) 55 | const project = new Project({ tsConfigFilePath: 'tsconfig.json' }) 56 | const eventHandlersIndex = project.createSourceFile( 57 | 'event-handlers.index.ts', 58 | indexSrc ? indexSrc.toString() : '', 59 | ) 60 | 61 | const moduleSpecifier = `./${options.event}.handler` 62 | const eventHandlerClass = `${strings.classify(options.event)}Handler` 63 | const namedImport = eventHandlersIndex.getImportDeclaration(moduleSpecifier) 64 | if (!namedImport) { 65 | eventHandlersIndex.addImportDeclaration({ 66 | moduleSpecifier, 67 | namedImports: [eventHandlerClass], 68 | }) 69 | } 70 | const exportAsArray = eventHandlersIndex.getVariableStatement('eventHandlers') 71 | if (!exportAsArray) { 72 | eventHandlersIndex.addVariableStatement({ 73 | declarationKind: VariableDeclarationKind.Const, 74 | declarations: [{ name: 'eventHandlers', initializer: `[${eventHandlerClass}]` }], 75 | isExported: true, 76 | }) 77 | } else { 78 | const array = exportAsArray.getDeclarations()[0].getInitializer() 79 | if (array) { 80 | exportAsArray 81 | .getDeclarations()[0] 82 | .setInitializer(mergeWithArrayString(array.getText(), eventHandlerClass)) 83 | } 84 | } 85 | eventHandlersIndex.formatText(formatCodeSettings) 86 | if (!tree.exists(indexPath)) { 87 | tree.create(indexPath, eventHandlersIndex.getFullText()) 88 | } else { 89 | tree.overwrite(indexPath, eventHandlersIndex.getFullText()) 90 | } 91 | 92 | return tree 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event-handler/event-handler.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "event-handler", 4 | "title": "EventHandlerSchema", 5 | "type": "object", 6 | "properties": { 7 | "event": { 8 | "type": "string" 9 | }, 10 | "aggregate": { 11 | "type": "string" 12 | }, 13 | "parameters": { 14 | "type": "array", 15 | "items": { 16 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 17 | } 18 | }, 19 | "imports": { 20 | "type": "array", 21 | "items": { 22 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 23 | } 24 | } 25 | }, 26 | "required": ["event", "aggregate"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event-handler/templates/event-handlers/__event__.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventHandler, IEventHandler } from '@sclable/nestjs-es-cqrs' 2 | 3 | import { <%= classify(event) %> } from '../events' 4 | 5 | @EventHandler(<%= classify(event) %>) 6 | export class <%= classify(event) %>Handler implements IEventHandler<<%= classify(event) %>> { 7 | public async handle(_event: <%= classify(event) %>) { 8 | /* no-op */ 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event/event.factory.ts: -------------------------------------------------------------------------------- 1 | import { Path, join, strings } from '@angular-devkit/core' 2 | import { 3 | Rule, 4 | Source, 5 | Tree, 6 | apply, 7 | chain, 8 | mergeWith, 9 | move, 10 | template, 11 | url, 12 | } from '@angular-devkit/schematics' 13 | import { Project, VariableDeclarationKind } from 'ts-morph' 14 | 15 | import { pastParticiple } from '../../past-participle' 16 | import { formatCodeSettings } from '../format' 17 | import { EsCqrsSchema } from '../schema' 18 | import { getImports, mergeWithArrayString } from '../utils' 19 | import { EventSchema } from './event.schema' 20 | 21 | export function main(options: EsCqrsSchema): Rule { 22 | return chain([standalone(transform(options))]) 23 | } 24 | 25 | export function standalone(options: EventSchema): Rule { 26 | return chain([mergeWith(generate(options)), updateIndex(options)]) 27 | } 28 | 29 | function transform(options: EsCqrsSchema): EventSchema { 30 | const parameters = options.parameters || [] 31 | 32 | return { 33 | event: `${strings.dasherize(options.subject)}-${pastParticiple(options.verb)}`, 34 | aggregate: strings.classify(options.moduleName), 35 | imports: getImports(parameters), 36 | parameters, 37 | needsEventData: 38 | parameters.length > 1 || (parameters.length !== 0 && !parameters[0].isExistingObject), 39 | } 40 | } 41 | 42 | function generate(options: EventSchema): Source { 43 | return apply(url('./templates'), [ 44 | template({ 45 | ...strings, 46 | ...options, 47 | }), 48 | move(join('src' as Path, strings.dasherize(options.aggregate))), 49 | ]) 50 | } 51 | 52 | function updateIndex(options: EventSchema): Rule { 53 | return (tree: Tree) => { 54 | const indexPath = join( 55 | 'src' as Path, 56 | strings.dasherize(options.aggregate), 57 | 'events', 58 | 'index.ts', 59 | ) 60 | const indexSrc = tree.read(indexPath) 61 | const project = new Project({ tsConfigFilePath: 'tsconfig.json' }) 62 | const eventsIndex = project.createSourceFile( 63 | 'events.index.ts', 64 | indexSrc ? indexSrc.toString() : '', 65 | ) 66 | const eventClassName = strings.classify(options.event) 67 | 68 | const moduleSpecifier = `./${options.event}.event` 69 | const namedImport = eventsIndex.getImportDeclaration(moduleSpecifier) 70 | if (!namedImport) { 71 | eventsIndex.addImportDeclaration({ 72 | moduleSpecifier, 73 | namedImports: [eventClassName], 74 | }) 75 | } 76 | 77 | const moduleEvents = `${options.aggregate}Events` 78 | const exportAsArray = eventsIndex.getVariableStatement(moduleEvents) 79 | if (!exportAsArray) { 80 | eventsIndex.addVariableStatement({ 81 | declarationKind: VariableDeclarationKind.Const, 82 | declarations: [{ name: moduleEvents, initializer: `[${eventClassName}]` }], 83 | isExported: true, 84 | }) 85 | } else { 86 | const array = exportAsArray.getDeclarations()[0].getInitializer() 87 | if (array) { 88 | exportAsArray 89 | .getDeclarations()[0] 90 | .setInitializer(mergeWithArrayString(array.getText(), eventClassName)) 91 | } 92 | } 93 | const namedExport = eventsIndex.getExportDeclaration(decl => !decl.hasModuleSpecifier()) 94 | if (!namedExport) { 95 | eventsIndex.addExportDeclaration({ 96 | namedExports: [eventClassName], 97 | }) 98 | } else { 99 | if ( 100 | !namedExport 101 | .getNamedExports() 102 | .map(ne => ne.getName()) 103 | .includes(eventClassName) 104 | ) { 105 | namedExport.addNamedExport(eventClassName) 106 | } 107 | } 108 | eventsIndex.formatText(formatCodeSettings) 109 | if (!tree.exists(indexPath)) { 110 | tree.create(indexPath, eventsIndex.getFullText()) 111 | } else { 112 | tree.overwrite(indexPath, eventsIndex.getFullText()) 113 | } 114 | 115 | return tree 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event/event.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "event", 4 | "title": "EventSchema", 5 | "type": "object", 6 | "properties": { 7 | "event": { 8 | "type": "string" 9 | }, 10 | "aggregate": { 11 | "type": "string" 12 | }, 13 | "needsEventData": { 14 | "type": "boolean" 15 | }, 16 | "parameters": { 17 | "type": "array", 18 | "items": { 19 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 20 | } 21 | }, 22 | "imports": { 23 | "type": "array", 24 | "items": { 25 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 26 | } 27 | } 28 | }, 29 | "required": ["event", "aggregate"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/event/templates/events/__event__.event.ts: -------------------------------------------------------------------------------- 1 | import { DefaultEvent } from '@sclable/nestjs-es-cqrs' 2 | <% for (let imp of imports) { %> 3 | import { <%= imp.imports.join(', ') %> } from '<%= imp.path %>'<% } %> 4 | <% if (needsEventData) { %> 5 | interface EventData {<% for (let param of parameters) { %> 6 | <%= param.name %>: <%= param.type %><% } %> 7 | } 8 | <% } %> 9 | export class <%= classify(event) %> extends DefaultEvent<<% if (needsEventData) %>EventData<% else %><%= parameters[0] ? parameters[0].type : '{}' %>> {} 10 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/format.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Tree, chain } from '@angular-devkit/schematics' 2 | import { ESLint } from 'eslint' 3 | import { Options, format as prettierFormat, resolveConfig } from 'prettier' 4 | import { FormatCodeSettings, SemicolonPreference } from 'typescript' 5 | 6 | export const formatCodeSettings: FormatCodeSettings = { 7 | indentSize: 2, 8 | semicolons: SemicolonPreference.Remove, 9 | } 10 | 11 | const defaultPrettierOptions: Options = { 12 | arrowParens: 'avoid', 13 | parser: 'typescript', 14 | printWidth: 140, 15 | semi: false, 16 | singleQuote: true, 17 | tabWidth: 2, 18 | trailingComma: 'all', 19 | } 20 | 21 | /** 22 | * Applies `prettier` formatting to the generated content 23 | * 24 | * If project has its own config it will be used, otherwise a default config is used 25 | */ 26 | export function prettier(): Rule { 27 | return async (tree: Tree) => { 28 | const prettierOptions = await resolveConfig(process.cwd()) 29 | tree.actions.forEach(action => { 30 | const src = tree.read(action.path) 31 | if (!src) { 32 | return 33 | } 34 | tree.overwrite( 35 | action.path, 36 | prettierFormat(src.toString(), prettierOptions ?? defaultPrettierOptions), 37 | ) 38 | }) 39 | } 40 | } 41 | 42 | /** 43 | * Applies `eslint` fixing to the generated content 44 | * 45 | * If project has its own config it will be used, otherwise a default config is used 46 | * WARNING! `prettier/prettier` rules doesn't seem to apply as it can't defer the correct parser 47 | */ 48 | export function eslint(): Rule { 49 | return async (tree: Tree) => { 50 | const eslint = new ESLint({ fix: true, extensions: ['.ts'] }) 51 | tree.actions.forEach(async action => { 52 | const src = tree.read(action.path) 53 | if (!src) { 54 | return 55 | } 56 | const lintResults = await eslint.lintText(src.toString()) 57 | tree.overwrite(action.path, lintResults[0].output ?? src) 58 | }) 59 | } 60 | } 61 | 62 | export function format(): Rule { 63 | return chain([prettier, eslint]) 64 | } 65 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/index.ts: -------------------------------------------------------------------------------- 1 | import { Rule, chain, schematic } from '@angular-devkit/schematics' 2 | 3 | import { EsCqrsSchema } from './schema' 4 | 5 | export function all(options: EsCqrsSchema): Rule { 6 | return chain([ 7 | schematic('command', options), 8 | schematic('command-handler', options), 9 | schematic('event', options), 10 | schematic('event-handler', options), 11 | schematic('aggregate', options), 12 | schematic('module', options), 13 | schematic('service', options), 14 | ]) 15 | } 16 | 17 | export function allWithRestController(options: EsCqrsSchema): Rule { 18 | return chain([all(options), schematic('controller', options)]) 19 | } 20 | 21 | export function allWithGraphqlResolver(options: EsCqrsSchema): Rule { 22 | return chain([all(options), schematic('resolver', options)]) 23 | } 24 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/module/module.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "module", 4 | "title": "ModuleSchema", 5 | "type": "object", 6 | "properties": { 7 | "aggregate": { 8 | "type": "string" 9 | } 10 | }, 11 | "required": ["aggregate"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/module/templates/__aggregate@dasherize__/__aggregate@dasherize__.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { ESCQRSModule } from '@sclable/nestjs-es-cqrs' 3 | 4 | import { <%= classify(aggregate) %> } from './<%= dasherize(aggregate) %>.aggregate' 5 | import { <%= classify(aggregate) %>Service } from './<%= dasherize(aggregate) %>.service' 6 | import { commandHandlers } from './command-handlers' 7 | import { eventHandlers } from './event-handlers' 8 | 9 | @Module({ 10 | imports: [ESCQRSModule.forFeature([<%= classify(aggregate) %>])], 11 | providers: [...commandHandlers, ...eventHandlers, <%= classify(aggregate) %>Service], 12 | exports: [<%= classify(aggregate) %>Service], 13 | }) 14 | export class <%= classify(aggregate) %>Module {} 15 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/nest6-migration/nest6-migration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "nest-6-migration", 4 | "title": "Nest6MigrationSchema" 5 | } 6 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "es-cqrs", 4 | "title": "EsCqrsSchema", 5 | "type": "object", 6 | "description": "EsCqrs", 7 | "properties": { 8 | "moduleName": { 9 | "type": "string" 10 | }, 11 | "verb": { 12 | "type": "string" 13 | }, 14 | "subject": { 15 | "type": "string" 16 | }, 17 | "parameters": { 18 | "type": "array", 19 | "items": { 20 | "$ref": "#/definitions/parameter" 21 | } 22 | }, 23 | "imports": { 24 | "type": "array", 25 | "items": { 26 | "$ref": "#/definitions/import" 27 | } 28 | } 29 | }, 30 | "required": ["verb", "subject", "moduleName"], 31 | "definitions": { 32 | "parameter": { 33 | "type": "object", 34 | "properties": { 35 | "name": { 36 | "type": "string" 37 | }, 38 | "type": { 39 | "type": "string" 40 | }, 41 | "graphqlType": { 42 | "type": "string" 43 | }, 44 | "importPath": { 45 | "type": "string" 46 | }, 47 | "isMember": { 48 | "type": "boolean" 49 | }, 50 | "isExistingObject": { 51 | "type": "boolean" 52 | } 53 | }, 54 | "required": ["name", "type"] 55 | }, 56 | "import": { 57 | "type": "object", 58 | "properties": { 59 | "path": { 60 | "type": "string" 61 | }, 62 | "imports": { 63 | "type": "array", 64 | "items": { 65 | "type": "string" 66 | } 67 | } 68 | }, 69 | "required": ["path", "imports"] 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/service/service.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "service", 4 | "title": "ServiceSchema", 5 | "type": "object", 6 | "properties": { 7 | "aggregate": { 8 | "type": "string" 9 | }, 10 | "command": { 11 | "type": "string" 12 | }, 13 | "isCreating": { 14 | "type": "boolean" 15 | }, 16 | "parameters": { 17 | "type": "array", 18 | "items": { 19 | "$ref": "src/es-cqrs/schema.json#/definitions/parameter" 20 | } 21 | }, 22 | "imports": { 23 | "type": "array", 24 | "items": { 25 | "$ref": "src/es-cqrs/schema.json#/definitions/import" 26 | } 27 | } 28 | }, 29 | "required": ["command", "aggregate"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/service/templates/__aggregate@dasherize__/__aggregate@dasherize__.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { CommandBus } from '@sclable/nestjs-es-cqrs' 3 | <% for (let imp of imports) { %> 4 | import { <%= imp.imports.join(', ') %> } from '<%= imp.path %>'<% } %> 5 | import { <%= classify(command) %> } from './commands' 6 | 7 | @Injectable() 8 | export class <%= classify(aggregate) %>Service { 9 | public constructor(private readonly commandBus: CommandBus) {} 10 | 11 | public async <%= camelize(command) %>(<%= parameters.map(p => `${p.name}: ${p.type}`).join(', ') %>): Promise<<%= isCreating ? 'string' : 'void' %>> { 12 | return this.commandBus.execute(new <%= classify(command) %>(<%= parameters.map(p => p.name).join(', ') %>)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/es-cqrs/utils.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile } from 'ts-morph' 2 | 3 | import { EsCqrsSchema, Import, Parameter } from './schema' 4 | 5 | export interface KeyValuesDefinition { 6 | [path: string]: string[] 7 | } 8 | 9 | const CREATION_VERBS = ['add', 'new', 'insert', 'create', 'make'] 10 | 11 | export function isCreating(schema: EsCqrsSchema): boolean { 12 | return ( 13 | schema.moduleName.toLowerCase() === schema.subject.toLowerCase() && 14 | CREATION_VERBS.includes(schema.verb.toLowerCase()) 15 | ) 16 | } 17 | 18 | export function updateImports(sourceFile: SourceFile, definition: KeyValuesDefinition): void { 19 | const importDeclarations = sourceFile.getImportDeclarations() 20 | Object.keys(definition).forEach(importPath => { 21 | const importDecl = importDeclarations.find( 22 | impDecl => impDecl.getModuleSpecifierValue() === importPath, 23 | ) 24 | if (importDecl) { 25 | definition[importPath].forEach(namedImport => { 26 | if ( 27 | !importDecl 28 | .getNamedImports() 29 | .map(ni => ni.getName()) 30 | .includes(namedImport) 31 | ) { 32 | importDecl.addNamedImport(namedImport) 33 | } 34 | }) 35 | } else { 36 | sourceFile.addImportDeclaration({ 37 | moduleSpecifier: importPath, 38 | namedImports: [...definition[importPath]], 39 | }) 40 | } 41 | }) 42 | } 43 | 44 | export function getImports(parameters: Parameter[]): Import[] { 45 | const importMap: Map> = new Map() 46 | parameters.forEach(param => { 47 | if (param.importPath) { 48 | if (!importMap.has(param.importPath)) { 49 | importMap.set(param.importPath, new Set()) 50 | } 51 | const importPathSet = importMap.get(param.importPath) 52 | importPathSet && importPathSet.add(param.type) 53 | } 54 | }) 55 | const imports: Import[] = [] 56 | importMap.forEach((namedImports, path) => imports.push({ path, imports: [...namedImports] })) 57 | 58 | return imports 59 | } 60 | 61 | export function mergeWithArrayString(array: string, item: string): string { 62 | const list = array 63 | .replace(/\[/, '') 64 | .replace(/\]$/, '') 65 | .split(',') 66 | .map(str => str.trim()) 67 | .filter(str => str.length > 0) 68 | if (!list.includes(item)) { 69 | list.push(item) 70 | } 71 | 72 | return `[\n${list.join(',\n')}\n]` 73 | } 74 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Rule, chain, schematic } from '@angular-devkit/schematics' 2 | import { readJsonSync } from 'fs-extra' 3 | 4 | import { format } from './es-cqrs/format' 5 | import { JsonInputSchema } from './json.schema' 6 | 7 | export function json(options: JsonInputSchema): Rule { 8 | const jsonObject = readJsonSync(options.jsonPath) 9 | const rules: Rule[] = [] 10 | if (Array.isArray(jsonObject)) { 11 | rules.push(...jsonObject.map(obj => schematic(options.rule, obj))) 12 | } else { 13 | rules.push(schematic(options.rule, jsonObject)) 14 | } 15 | if (!options.skipFormat) { 16 | rules.push(format()) 17 | } 18 | 19 | return chain(rules) 20 | } 21 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/json.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "json-input", 4 | "title": "JsonInputSchema", 5 | "type": "object", 6 | "properties": { 7 | "jsonPath": { 8 | "type": "string", 9 | "description": "json to load", 10 | "$default": { 11 | "$source": "argv", 12 | "index": 0 13 | }, 14 | "x-prompt": "JSON filename" 15 | }, 16 | "rule": { 17 | "type": "string", 18 | "default": "all", 19 | "description": "rule to use", 20 | "$default": { 21 | "$source": "argv", 22 | "index": 1 23 | }, 24 | "x-prompt": "Rule [all|command|event|command-handler|event-handler|aggregate|client-service|nest6-migration]" 25 | }, 26 | "skipFormat": { 27 | "type": "boolean", 28 | "default": "false" 29 | } 30 | }, 31 | "required": ["jsonPath", "rule"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/es-cqrs-schematics/src/past-participle/index.ts: -------------------------------------------------------------------------------- 1 | import { irregularVerbs } from './irregularVerbs' 2 | 3 | export function pastParticiple(infinitive: string): string { 4 | const lowerInfinitive = infinitive.toLowerCase() 5 | const irregularForm = irregularVerbs.get(lowerInfinitive) 6 | 7 | return irregularForm ? irregularForm : regular(lowerInfinitive) 8 | } 9 | 10 | function regular(present: string): string { 11 | return present + (present.endsWith('e') ? '' : 'e') + 'd' 12 | } 13 | -------------------------------------------------------------------------------- /packages/es-cqrs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src' 2 | -------------------------------------------------------------------------------- /packages/es-cqrs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-es-cqrs", 3 | "version": "10.0.6", 4 | "description": "Sclable ES-CQRS library for nestjs", 5 | "author": { 6 | "name": "Sclable Business Solutions GmbH", 7 | "email": "office@sclable.com", 8 | "url": "https://www.sclable.com/" 9 | }, 10 | "contributors": [ 11 | "Adam Koleszar " 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:sclable/nestjs-libs.git" 16 | }, 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "license": "MIT", 23 | "publishConfig": { 24 | "registry": "https://registry.npmjs.org", 25 | "access": "public" 26 | }, 27 | "dependencies": { 28 | "@babel/runtime": "^7.9.2", 29 | "@nestjs/cqrs": "10.2.7", 30 | "lodash": "^4.17.12", 31 | "p-limit": "^3.1.0", 32 | "uuid": "^10.0.0" 33 | }, 34 | "peerDependencies": { 35 | "@nestjs/common": "^10", 36 | "@nestjs/core": "^10" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/aggregate.ts: -------------------------------------------------------------------------------- 1 | import { AggregateRoot } from '@nestjs/cqrs' 2 | 3 | import { Event, EventConstructor, EventSourcedAggregate } from './interfaces' 4 | 5 | /** 6 | * Aggregate class that has an id and a revision number 7 | * 8 | * Use the [[EventSourcableAggregate]] decorator so that the ES-CQRS module can register possible events. 9 | * [[EventRegistry]]. 10 | * 11 | * Implement event handlers inside with the name `on${EventClassName}` to change the aggregates upon events. 12 | * 13 | * Creation events should get a revision of `1`. Modifier events should get an incremented value from the aggregate's 14 | * current revision. To make this easier call `this.uppedRevision()` for both. 15 | * 16 | * Example: 17 | * ```typescript 18 | * import { Aggregate, Event } from '@sclable/es-cqrs' 19 | * import { AccountCreatedEvent, AccountNameChangedEvent } from './events' 20 | * import { v4 as uuidv4 } from 'uuid' 21 | * 22 | * @EventSourcableAggregate(AccountCreatedEvent, AccountNameChangedEvent) 23 | * export class AccountAggregate extends Aggregate { 24 | * accountName: string 25 | * 26 | * public static create(id: string, userId: string) { 27 | * const self = new AccountAggregate(id, userId) 28 | * self.apply(new AccountCreatedEvent(id, self.uppedRevision())) 29 | * 30 | * return self 31 | * } 32 | * 33 | * public changeName(accountName: string) { 34 | * this.apply(new AccountNameChangedEvent(this.id, this.uppedRevision(), {accountName})) 35 | * } 36 | * 37 | * public onAccountCreatedEvent(event: AccountCreatedEvent) { 38 | * this.accountName = 'default' 39 | * } 40 | * 41 | * public onAccountNameChangedEvent(event: AccountNameChangedEvent) { 42 | * this.accountName = event.data.name 43 | * } 44 | * } 45 | * ``` 46 | * 47 | */ 48 | export class Aggregate extends AggregateRoot implements EventSourcedAggregate { 49 | public revision: number = 0 50 | 51 | public constructor(public readonly id: string, public readonly userId: string) { 52 | super() 53 | } 54 | 55 | public getUncommittedEvents(): Event[] { 56 | return super.getUncommittedEvents() as Event[] 57 | } 58 | 59 | public apply( 60 | event: Event, 61 | options: boolean | { isFromHistory?: boolean; skipHandler?: boolean } = false, 62 | ): void { 63 | super.apply(event, options as boolean) 64 | this.revision = event.revision 65 | } 66 | 67 | /** 68 | * @return an incremented revision for events 69 | */ 70 | public uppedRevision(): number { 71 | return this.revision + 1 72 | } 73 | 74 | /** 75 | * Apply event helper 76 | * 77 | * Takes care of upping the revision and saving the user ID 78 | * 79 | * @param event event class 80 | * @param data event data 81 | * @typeParam T event data type 82 | */ 83 | protected applyEvent(event: EventConstructor, data: T): void { 84 | this.apply( 85 | new event( 86 | this.id, 87 | Object.getPrototypeOf(this).constructor.name, 88 | this.uppedRevision(), 89 | new Date(), 90 | this.userId, 91 | data, 92 | ), 93 | ) 94 | } 95 | } 96 | 97 | export type AggregateConstructor = new (id: string, userId: string) => T 98 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/cqrs.module.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Module, OnModuleInit } from '@nestjs/common' 2 | import { CommandBus, UnhandledExceptionBus } from '@nestjs/cqrs' 3 | 4 | import { ExplorerService } from './explorer.service' 5 | import { RateLimitedEventBus } from './rate-limited-event-bus' 6 | 7 | @Module({ 8 | exports: [CommandBus, RateLimitedEventBus], 9 | providers: [CommandBus, ExplorerService, RateLimitedEventBus, UnhandledExceptionBus], 10 | }) 11 | export class CqrsModule implements OnModuleInit { 12 | public constructor( 13 | @Inject(ExplorerService) private readonly explorerService: ExplorerService, 14 | @Inject(RateLimitedEventBus) 15 | private readonly eventBus: RateLimitedEventBus, 16 | @Inject(CommandBus) private readonly commandBus: CommandBus, 17 | ) {} 18 | 19 | public onModuleInit(): void { 20 | const { events, commands } = this.explorerService.explore() 21 | 22 | this.eventBus.register(events) 23 | this.commandBus.register(commands) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/decorators/constants.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export const EVENTS_ON_AGGREGATE_METADATA = 'EventsOnAggregate' 3 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/decorators/event-sourcable-aggregate.decorator.ts: -------------------------------------------------------------------------------- 1 | import { EventConstructor } from '../interfaces' 2 | import { EVENTS_ON_AGGREGATE_METADATA } from './constants' 3 | 4 | /** 5 | * Decorator to define events that can affect the aggregate 6 | */ 7 | export function EventSourcableAggregate(...events: EventConstructor[]): ClassDecorator { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | return (target: any) => Reflect.defineMetadata(EVENTS_ON_AGGREGATE_METADATA, events, target) 10 | } 11 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export { EVENTS_ON_AGGREGATE_METADATA } from './constants' 2 | export { EventSourcableAggregate } from './event-sourcable-aggregate.decorator' 3 | export { InjectRepository } from './inject-repository.decorator' 4 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/decorators/inject-repository.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common' 2 | 3 | import { Aggregate, AggregateConstructor } from '../aggregate' 4 | import { getRepositoryToken } from '../repository' 5 | 6 | /** 7 | * Aggregate repository injector for command handlers 8 | */ 9 | export const InjectRepository = ( 10 | aggregate: AggregateConstructor, 11 | ): ReturnType => Inject(getRepositoryToken(aggregate)) 12 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/default-event.ts: -------------------------------------------------------------------------------- 1 | import { CustomEventOptions, Event } from './interfaces' 2 | 3 | /** 4 | * Default [[Event]] implementation 5 | * 6 | * Usage: 7 | * ```typescript 8 | * interface EventData { 9 | * param1: string, 10 | * param2: number, 11 | * } 12 | * 13 | * export class SomeEvent extends DefaultEvent {} 14 | * ``` 15 | */ 16 | export class DefaultEvent implements Event { 17 | public constructor( 18 | public readonly aggregateId: string, 19 | public readonly aggregateType: string, 20 | public readonly revision: number, 21 | public readonly createdAt: Date, 22 | public readonly userId: string, 23 | public readonly data: T, 24 | public readonly customOptions?: CustomEventOptions, 25 | ) {} 26 | } 27 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/aggregate-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Exception for missing aggregate 3 | */ 4 | export class AggregateNotFoundException extends Error { 5 | public constructor(public readonly id: string) { 6 | super('Cannot find aggregate') 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/event-registry.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { EventConstructor } from '../interfaces' 4 | import { EventStoreException } from './event-store.exception' 5 | 6 | /** 7 | * @hidden 8 | * The EventRegisty stores constructors for events to reconstruct them when they come back from the [[EventStoreProvider]] 9 | */ 10 | @Injectable() 11 | export class EventRegistry { 12 | private map: Map = new Map() 13 | 14 | public get(name: string): EventConstructor { 15 | const result = this.map.get(name) 16 | if (result === undefined) { 17 | throw new EventStoreException(`Unknown event type ${name}`, {}) 18 | } 19 | 20 | return result 21 | } 22 | 23 | public register(name: string, event: EventConstructor): void { 24 | this.map.set(name, event) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/event-store-options.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces' 2 | 3 | /** @hidden */ 4 | export const EVENT_STORE_OPTIONS = 'EventStoreOptions' 5 | 6 | export interface EventStoreOptions { 7 | /** 8 | * Default snapshot interval is **5** events by aggregate 9 | */ 10 | snapshotInterval?: number 11 | 12 | /** 13 | * Enables logging to the `@nestjs` logger 14 | */ 15 | logging?: boolean 16 | 17 | /** 18 | * Enables logging of exceptions 19 | */ 20 | debug?: boolean 21 | } 22 | 23 | /** 24 | * Implement this factory interface to create a options for the event store 25 | * 26 | * Example: 27 | * ```typescript 28 | * class OptionsFactory implements EventStoreOptionsFactory { 29 | * public async createEventStoreOptions() { 30 | * return { logging: true } 31 | * } 32 | * } 33 | * ``` 34 | */ 35 | export interface EventStoreOptionsFactory { 36 | createEventStoreOptions(): Promise | EventStoreOptions 37 | } 38 | 39 | /** 40 | * Asyncronous options for the event store 41 | * 42 | * Example (`useFactory`): `app.module.ts` 43 | * ```typescript 44 | * @Module({ 45 | * imports: [ 46 | * ESCQRSModule.forRootAsync({ 47 | * inject: [GlobalOptionsProvider] 48 | * useFactory: async (globalOptions: GlobalOptionsProvider) => { 49 | * return await globalOptions.getEventStoreOptions() 50 | * } 51 | * }), 52 | * MyModule, 53 | * ] 54 | * }) 55 | * ``` 56 | * 57 | * Example (`useClass` / `useExisting`): `app.module.ts` 58 | * ```typescript 59 | * class OptionsFactory implements EventStoreOptionsFactory { 60 | * public async createEventStoreOptions() { 61 | * return { logging: true } 62 | * } 63 | * } 64 | * 65 | * @Module({ 66 | * imports: [ 67 | * ESCQRSModule.forRootAsync({ 68 | * useClass: OptionsFactory 69 | * }), 70 | * MyModule, 71 | * ] 72 | * }) 73 | * ``` 74 | * 75 | * It is also possible to add additional imports for the options factory if the service required is in another module 76 | * 77 | * Example (`imports`): `app.module.ts` 78 | * ```typescript 79 | * @Module({ 80 | * imports: [ 81 | * ESCQRSModule.forRootAsync({ 82 | * imports: [GlobalOptionsModule] 83 | * inject: [GlobalOptionsProvider] 84 | * useFactory: async (globalOptions: GlobalOptionsProvider) => { 85 | * return await globalOptions.getEventStoreOptions() 86 | * } 87 | * }), 88 | * MyModule, 89 | * ] 90 | * }) 91 | * ``` 92 | */ 93 | export interface EventStoreAsyncOptions extends Pick { 94 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 95 | inject?: any[] 96 | useClass?: Type 97 | useExisting?: Type 98 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 99 | useFactory?: (...args: any[]) => Promise | EventStoreOptions 100 | } 101 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/event-store.exception.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventSourcedAggregate } from '../interfaces' 2 | 3 | export interface EventStoreExceptionInfo { 4 | events?: Event[] 5 | revision?: number 6 | aggregate?: EventSourcedAggregate 7 | originalMessage?: string 8 | } 9 | /** 10 | * General exception for event store related errors 11 | */ 12 | export class EventStoreException extends Error { 13 | public constructor(msg: string, public readonly info: EventStoreExceptionInfo) { 14 | super(msg) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/event-store.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DynamicModule, 3 | Global, 4 | Inject, 5 | Logger, 6 | Module, 7 | OnModuleDestroy, 8 | Provider, 9 | } from '@nestjs/common' 10 | import { ModuleRef } from '@nestjs/core' 11 | 12 | import { CqrsModule } from '../cqrs.module' 13 | import { 14 | AsyncProvider, 15 | EVENT_STORE_PROVIDER, 16 | EventStoreProvider, 17 | createAsyncProviders, 18 | } from '../interfaces' 19 | import { EventRegistry } from './event-registry' 20 | import { EVENT_STORE_OPTIONS, EventStoreOptions } from './event-store-options' 21 | import { ReplayService } from './replay.service' 22 | 23 | /** @hidden */ 24 | @Global() 25 | @Module({}) 26 | export class EventStoreModule implements OnModuleDestroy { 27 | public constructor( 28 | @Inject(ModuleRef) private readonly moduleRef: ModuleRef, 29 | @Inject(Logger) private readonly logger: Logger, 30 | ) {} 31 | 32 | public static forRoot( 33 | options: EventStoreOptions, 34 | eventStoreProvider: AsyncProvider, 35 | ): DynamicModule { 36 | const optionsProvider: Provider = { 37 | provide: EVENT_STORE_OPTIONS, 38 | useValue: options, 39 | } 40 | 41 | const asyncProviders = createAsyncProviders(eventStoreProvider, EVENT_STORE_PROVIDER) 42 | 43 | return { 44 | exports: [optionsProvider, EventRegistry, ReplayService, ...asyncProviders], 45 | imports: [CqrsModule], 46 | module: EventStoreModule, 47 | providers: [optionsProvider, ReplayService, EventRegistry, Logger, ...asyncProviders], 48 | } 49 | } 50 | 51 | public static forRootAsync( 52 | options: AsyncProvider, 53 | eventStoreProvider: AsyncProvider, 54 | ): DynamicModule { 55 | const asyncProviders = createAsyncProviders(options, EVENT_STORE_OPTIONS) 56 | asyncProviders.push(...createAsyncProviders(eventStoreProvider, EVENT_STORE_PROVIDER)) 57 | 58 | return { 59 | exports: [EventRegistry, ReplayService, ...asyncProviders], 60 | imports: [CqrsModule, ...(options.imports ? options.imports : [])], 61 | module: EventStoreModule, 62 | providers: [ReplayService, EventRegistry, Logger, ...asyncProviders], 63 | } 64 | } 65 | 66 | public async onModuleDestroy(): Promise { 67 | const eventStore = this.moduleRef.get(EVENT_STORE_PROVIDER) 68 | await eventStore.close() 69 | this.logger.log('Eventstore closed', 'EventStoreModule') 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/index.ts: -------------------------------------------------------------------------------- 1 | export { AggregateNotFoundException } from './aggregate-not-found.exception' 2 | export { EventRegistry } from './event-registry' 3 | export { 4 | EVENT_STORE_OPTIONS, 5 | EventStoreAsyncOptions, 6 | EventStoreOptions, 7 | EventStoreOptionsFactory, 8 | } from './event-store-options' 9 | export { EventStoreException } from './event-store.exception' 10 | export { EventStoreModule } from './event-store.module' 11 | export { InmemoryEventStore, InmemoryEventStoreProvider } from './inmemory-event-store' 12 | export { ReplayOptions } from './replay-options' 13 | export { ReplayFinished, ReplayService } from './replay.service' 14 | export { RevisionConflictException } from './revision-conflict.exception' 15 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/inmemory-event-store.ts: -------------------------------------------------------------------------------- 1 | import { Aggregate } from '../aggregate' 2 | import { AsyncProvider, Event, EventSourcedAggregate, EventStoreProvider } from '../interfaces' 3 | import { EVENT_STORE_OPTIONS, EventStoreOptions } from './event-store-options' 4 | import { ReplayOptions } from './replay-options' 5 | 6 | const replayFilter = (options: ReplayOptions) => (event: Event, idx: number) => { 7 | let filter = true 8 | if (options.fromPosition || options.toPosition) { 9 | filter = 10 | filter && 11 | idx >= (options.fromPosition || 1) && 12 | idx <= (options.toPosition || 2 ** 31 - 1) 13 | } 14 | if (options.fromDate || options.toDate) { 15 | filter = 16 | filter && 17 | event.createdAt >= (options.fromDate || new Date('1900-01-01')) && 18 | event.createdAt <= (options.toDate || new Date()) 19 | } 20 | if (options.fromRevision || options.toRevision) { 21 | filter = 22 | filter && 23 | event.revision >= (options.fromRevision || 1) && 24 | event.revision <= (options.toRevision || 2 ** 31 - 1) 25 | } 26 | if (options.aggregateId) { 27 | filter = filter && event.aggregateId === options.aggregateId 28 | } 29 | if (options.aggregateType) { 30 | filter = filter && event.aggregateType === options.aggregateType 31 | } 32 | if (options.eventName) { 33 | filter = filter && Object.getPrototypeOf(event).constructor.name === options.eventName 34 | } 35 | 36 | return filter 37 | } 38 | 39 | /** 40 | * Default inmemory event-store implementation 41 | * 42 | * Simplified version of inmemory wolkenkit-eventstore to demonstrate implementing a custom event-store 43 | */ 44 | export class InmemoryEventStore implements EventStoreProvider { 45 | private db: { events: Event[]; snapshots: EventSourcedAggregate[] } 46 | public constructor(private readonly options: EventStoreOptions) { 47 | this.db = { 48 | events: [], 49 | snapshots: [], 50 | } 51 | } 52 | public async getEvents(aggregateId: string, fromRevision: number): Promise { 53 | return this.db.events.filter( 54 | event => event.aggregateId === aggregateId && event.revision >= fromRevision, 55 | ) 56 | } 57 | public async saveEvents(aggregate: Aggregate): Promise { 58 | const events = aggregate.getUncommittedEvents() 59 | this.db.events.push(...events) 60 | } 61 | public async getSnapshot(aggregateId: string): Promise { 62 | const snaps = this.db.snapshots.filter(snap => snap.id === aggregateId) 63 | const latestRevision = Math.max(...snaps.map(snap => snap.revision)) 64 | 65 | return snaps.find(snap => snap.revision === latestRevision) 66 | } 67 | public async saveSnapshot(aggregate: EventSourcedAggregate): Promise { 68 | if ( 69 | !this.options.snapshotInterval || 70 | aggregate.revision % this.options.snapshotInterval !== 1 71 | ) { 72 | return 73 | } 74 | this.db.snapshots.push(aggregate) 75 | } 76 | public async getReplay(options?: ReplayOptions): Promise { 77 | if (!options) { 78 | return this.db.events 79 | } 80 | 81 | return this.db.events.filter(replayFilter(options)) 82 | } 83 | public async getReplayCount(options?: ReplayOptions): Promise { 84 | if (!options) { 85 | return this.db.events.length 86 | } 87 | 88 | return this.db.events.filter(replayFilter(options)).length 89 | } 90 | public async init(): Promise { 91 | /* no-op */ 92 | } 93 | public async close(): Promise { 94 | /* no-op */ 95 | } 96 | } 97 | 98 | export const InmemoryEventStoreProvider: AsyncProvider = { 99 | inject: [EVENT_STORE_OPTIONS], 100 | useFactory: options => new InmemoryEventStore(options), 101 | } 102 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/replay-options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Options to filter replayable events 3 | * 4 | * Filters are applied with AND between them 5 | */ 6 | export interface ReplayOptions { 7 | /** Minimun position is 1, filter is expected to be inclusive */ 8 | fromPosition?: number 9 | /** Maximum position is MAX_INT, filter is expected to be inclusive */ 10 | toPosition?: number 11 | aggregateId?: string 12 | aggregateType?: string 13 | /** Minimun revision is 1, filter is expected to be inclusive */ 14 | fromRevision?: number 15 | /** Maximum revision is MAX_INT, filter is expected to be inclusive */ 16 | toRevision?: number 17 | eventName?: string 18 | /** Minimun date is epoch, filter is expected to be inclusive */ 19 | fromDate?: Date 20 | /** Maximum date is now, filter is expected to be inclusive */ 21 | toDate?: Date 22 | /** 23 | * If paging size is not given, the default is 1000 24 | * 25 | * Please choose a number suitable for your application and DB 26 | */ 27 | pagingSize?: number 28 | } 29 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/replay.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { DefaultEvent } from '../default-event' 5 | import { EVENT_STORE_PROVIDER, EventStoreProvider } from '../interfaces' 6 | import { RateLimitedEventBus } from '../rate-limited-event-bus' 7 | import { EVENT_STORE_OPTIONS, EventStoreOptions } from './event-store-options' 8 | import { ReplayOptions } from './replay-options' 9 | 10 | const REPLAY_MAX_PAGING_SIZE = 1000 11 | 12 | /** 13 | * Last event appended to replay list 14 | * 15 | * Implement a handler for this event to know when the replay actually finishes 16 | */ 17 | export class ReplayFinished extends DefaultEvent> {} 18 | 19 | /** 20 | * This service is used to replay events from the event store 21 | * 22 | * Can be run in a separate script. 23 | * 24 | * Example: `replay.ts` 25 | * ```typescript 26 | * @EventHandler(ReplayFinished) 27 | * class ReplayFinishedHandler implements IEventHandler { 28 | * public handle() { 29 | * console.log('Replay finished') 30 | * } 31 | * } 32 | * 33 | * @Module({ 34 | * imports: [ 35 | * ESCQRSModule.forRoot({...}), 36 | * FeatureModule1, 37 | * FeatureModule2, 38 | * ], 39 | * }) 40 | * class ResetAndReplayModule {} 41 | * 42 | * async function run() { 43 | * const app = await NestFactory.create(ResetAndReplayModule) 44 | * await app.init() 45 | * 46 | * const replayService = app.get(ReplayService) 47 | * 48 | * await replayService.replay() 49 | * await app.close() 50 | * } 51 | * 52 | * run() 53 | * ``` 54 | */ 55 | @Injectable() 56 | export class ReplayService { 57 | public constructor( 58 | @Inject(EVENT_STORE_PROVIDER) private readonly eventStore: EventStoreProvider, 59 | @Inject(RateLimitedEventBus) private readonly eventBus: RateLimitedEventBus, 60 | @Inject(Logger) private readonly logger: Logger, 61 | @Inject(EVENT_STORE_OPTIONS) private readonly options: EventStoreOptions, 62 | ) {} 63 | 64 | /** 65 | * @param replayOptions replay options 66 | * @returns number of events replayed 67 | */ 68 | public async replay(replayOptions?: ReplayOptions): Promise { 69 | const queueById = uuidv4() // events have to have the same id so that they are run in a single queue 70 | let pagingSize = REPLAY_MAX_PAGING_SIZE 71 | if (replayOptions?.pagingSize) { 72 | pagingSize = replayOptions.pagingSize 73 | } 74 | const eventCount = await this.eventStore.getReplayCount(replayOptions) 75 | const pageCount = Math.ceil(eventCount / pagingSize) 76 | for (let i = 0; i < pageCount; i++) { 77 | if (!replayOptions) { 78 | replayOptions = { 79 | fromPosition: i * pagingSize + 1, 80 | toPosition: (i + 1) * pagingSize, 81 | } 82 | } else { 83 | replayOptions.fromPosition = (replayOptions.fromPosition || 1) + i * pagingSize 84 | replayOptions.toPosition = replayOptions.fromPosition + pagingSize - 1 85 | } 86 | const events = await this.eventStore.getReplay(replayOptions) 87 | 88 | events.forEach(event => { 89 | event.customOptions = { queueById } 90 | this.eventBus.publish(event) 91 | }) 92 | } 93 | 94 | this.eventBus.publish( 95 | new ReplayFinished(uuidv4(), 'Replay', 1, new Date(), uuidv4(), {}, { queueById }), 96 | ) 97 | if (this.options.logging) { 98 | this.logger.log(`Replayed ${eventCount} events`, 'ReplayService') 99 | } 100 | 101 | return eventCount 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/event-store/revision-conflict.exception.ts: -------------------------------------------------------------------------------- 1 | import { EventStoreException, EventStoreExceptionInfo } from './event-store.exception' 2 | 3 | /** 4 | * Exception for missing aggregate 5 | */ 6 | export class RevisionConflictException extends EventStoreException { 7 | public constructor(info: EventStoreExceptionInfo) { 8 | super('Revision conflict detected', info) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/explorer.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Inject, Injectable, Type } from '@nestjs/common' 3 | import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper' 4 | import { Module } from '@nestjs/core/injector/module' 5 | import { ModulesContainer } from '@nestjs/core/injector/modules-container' 6 | import { ICommandHandler, IEventHandler } from '@nestjs/cqrs' 7 | import { 8 | COMMAND_HANDLER_METADATA, 9 | EVENTS_HANDLER_METADATA, 10 | } from '@nestjs/cqrs/dist/decorators/constants' 11 | 12 | export interface CqrsOptions { 13 | events?: Type[] 14 | commands?: Type[] 15 | } 16 | 17 | @Injectable() 18 | export class ExplorerService { 19 | public constructor( 20 | @Inject(ModulesContainer) 21 | private readonly modulesContainer: ModulesContainer, 22 | ) {} 23 | 24 | public explore(): CqrsOptions { 25 | const modules = [...this.modulesContainer.values()] 26 | const commands = this.flatMap(modules, instance => 27 | this.filterProvider(instance, COMMAND_HANDLER_METADATA), 28 | ) 29 | const events = this.flatMap(modules, instance => 30 | this.filterProvider(instance, EVENTS_HANDLER_METADATA), 31 | ) 32 | 33 | return { commands, events } 34 | } 35 | 36 | public flatMap( 37 | modules: Module[], 38 | callback: (instance: InstanceWrapper) => Type | undefined, 39 | ): Type[] { 40 | const items = modules 41 | .map(module => [...module.providers.values()].map(callback)) 42 | .reduce((all, prvs) => all.concat(prvs), []) 43 | 44 | return items.filter(element => !!element) as Type[] 45 | } 46 | 47 | public filterProvider(wrapper: InstanceWrapper, metadataKey: string): Type | undefined { 48 | const { instance } = wrapper 49 | if (!instance) { 50 | return undefined 51 | } 52 | 53 | return this.extractMetadata(instance, metadataKey) 54 | } 55 | 56 | public extractMetadata( 57 | instance: Record, 58 | metadataKey: string, 59 | ): Type | undefined { 60 | if (!instance.constructor) { 61 | return undefined 62 | } 63 | const metadata = Reflect.getMetadata(metadataKey, instance.constructor) 64 | 65 | return metadata ? (instance.constructor as Type) : undefined 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/index.ts: -------------------------------------------------------------------------------- 1 | export { EventSourcableAggregate, InjectRepository } from './decorators' 2 | export { DefaultEvent } from './default-event' 3 | export { 4 | EVENT_STORE_OPTIONS, 5 | AggregateNotFoundException, 6 | EventRegistry, 7 | EventStoreAsyncOptions, 8 | EventStoreException, 9 | EventStoreOptions, 10 | EventStoreOptionsFactory, 11 | InmemoryEventStore, 12 | InmemoryEventStoreProvider, 13 | ReplayFinished, 14 | ReplayOptions, 15 | ReplayService, 16 | RevisionConflictException, 17 | } from './event-store' 18 | export { ESCQRSModule } from './es-cqrs.module' 19 | export { Repository } from './repository' 20 | export { RateLimitedEventBus as EventBus } from './rate-limited-event-bus' 21 | export * from './interfaces' 22 | export { Aggregate, AggregateConstructor } from './aggregate' 23 | export { 24 | CommandBus, 25 | CommandHandler, 26 | EventBus as NestEventBus, 27 | EventsHandler as EventHandler, 28 | ICommandHandler, 29 | IEventHandler, 30 | } from '@nestjs/cqrs' 31 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/async-provider.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata, Provider, Type } from '@nestjs/common/interfaces' 2 | 3 | export interface AsyncProviderFactory { 4 | create(): Promise | T 5 | } 6 | 7 | export interface AsyncProvider extends Pick { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | inject?: any[] 10 | useClass?: Type> 11 | useExisting?: Type> 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | useFactory?: (...args: any[]) => Promise | T 14 | } 15 | 16 | export function createAsyncProvider(provider: AsyncProvider, token: string): Provider { 17 | if (provider.useFactory) { 18 | return { 19 | inject: provider.inject || [], 20 | provide: token, 21 | useFactory: provider.useFactory, 22 | } 23 | } 24 | 25 | let toInject 26 | if (provider.useClass === undefined) { 27 | if (provider.useExisting === undefined) { 28 | throw new Error( 29 | 'at least one of the two provider useClass and useExisting must not be undefined', 30 | ) 31 | } else { 32 | toInject = provider.useExisting 33 | } 34 | } else { 35 | toInject = provider.useClass 36 | } 37 | 38 | return { 39 | inject: [toInject], 40 | provide: token, 41 | useFactory: async (providerFactory: AsyncProviderFactory) => 42 | await providerFactory.create(), 43 | } 44 | } 45 | 46 | export function createAsyncProviders( 47 | provider: AsyncProvider, 48 | token: string, 49 | ): Provider[] { 50 | if (provider.useExisting || provider.useFactory) { 51 | return [createAsyncProvider(provider, token)] 52 | } else if (provider.useClass) { 53 | return [ 54 | createAsyncProvider(provider, token), 55 | { 56 | provide: provider.useClass, 57 | useClass: provider.useClass, 58 | }, 59 | ] 60 | } else { 61 | return [] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/command.ts: -------------------------------------------------------------------------------- 1 | import { ICommand as INestCommand } from '@nestjs/cqrs' 2 | 3 | /** 4 | * Command interface 5 | * 6 | * Implement this to create unique commands. There is no restriction on what the command class can have. 7 | * 8 | * Example: 9 | * ```typescript 10 | * import { Command } from '@sclable/es-cqrs' 11 | * import { v4 } from 'uuid' 12 | * 13 | * export class CreateAccountCommand implements Command {} 14 | * 15 | * export class AccountNameChangeCommand implements Command { 16 | * public constructor(public readonly id: string, public readonly name: string) {} 17 | * } 18 | * ``` 19 | * 20 | * To handle these commands implement the `ICommandHandler` interface so that it finds or creates the needed aggregate, 21 | * call the modifying method that will create the event and persist the aggregate in the repository. The `resolve` 22 | * method should return the object needed for this command to finish (usually an ID). 23 | * 24 | * Example: 25 | * ```typescript 26 | * import { CommandHandler, ICommandHandler, InjectRepository, Repository } from '@sclable/es-cqrs' 27 | * import { AccountNameChangeCommand } from './commands' 28 | * import { AccountAggregate } from './aggregates' 29 | * 30 | * @CommandHandler(AccountNameChangeCommand) 31 | * export class SomeCommandHandler implements ICommandHandler { 32 | * 33 | * public constructor(@InjectRepository(AccountAggregate) private readonly repo: Repository) {} 34 | * 35 | * async execute(command: AccountNameChangeCommand): Promise { 36 | * const agg = await this.repo.find(command.id) 37 | * agg.changeName(command.name) 38 | * return this.repo.persist(agg) 39 | * } 40 | * } 41 | * ``` 42 | */ 43 | export type Command = INestCommand 44 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/custom-event-options.ts: -------------------------------------------------------------------------------- 1 | export interface CustomEventOptions { 2 | /** 3 | * Custom ID to use for the event-handler queue 4 | * 5 | * Useful to group together event-handlers that belong to different aggregates but depend on each other 6 | */ 7 | queueById?: string 8 | 9 | /** Skip the event-handler from queueing */ 10 | skipQueue?: boolean 11 | 12 | /** Version of the event */ 13 | version?: number 14 | } 15 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/event-sourced-aggregate.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export interface EventSourcedAggregate { 3 | id: string 4 | revision: number 5 | userId?: string 6 | } 7 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/event-store-provider.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | 3 | import { Aggregate } from '../aggregate' 4 | import { EventRegistry } from '../event-store/event-registry' 5 | import { EventStoreOptions } from '../event-store/event-store-options' 6 | import { ReplayOptions } from '../event-store/replay-options' 7 | import { Event } from './event' 8 | import { EventSourcedAggregate } from './event-sourced-aggregate' 9 | 10 | /** @hidden */ 11 | export const EVENT_STORE_PROVIDER = 'EventStoreProvider' 12 | 13 | /** 14 | * EventStore provider interface 15 | * 16 | * The user is responsible for implementing this interface to provide an event-store. If it is ommited, the default 17 | * `wolkenkit-eventstore` will be used 18 | */ 19 | export interface EventStoreProvider { 20 | /** Get events for an aggregate */ 21 | getEvents(aggregateId: string, fromRevision: number): Promise 22 | /** 23 | * Save events for an aggregate 24 | * 25 | * Events are accessed through `Aggregate.getUncommittedEvents()` 26 | */ 27 | saveEvents(aggregate: Aggregate): Promise 28 | /** Get the latest snapshot of an aggregate */ 29 | getSnapshot(aggregateId: string): Promise 30 | /** Save a snapshot of an aggregate */ 31 | saveSnapshot(aggregate: EventSourcedAggregate): Promise 32 | /** Get events to replay (default is all) */ 33 | getReplay(options?: ReplayOptions): Promise 34 | /** Get count of events to replay (default is all) */ 35 | getReplayCount(options?: ReplayOptions): Promise 36 | /** Init event-store */ 37 | init(): Promise 38 | /** Close event-store */ 39 | close(): Promise 40 | } 41 | 42 | /** 43 | * EventStore provider factory 44 | * 45 | * Implement this factory to provide a custom event-store 46 | */ 47 | export interface EventStoreProviderFactory { 48 | /** 49 | * Create event-store 50 | * 51 | * @param options event-store options 52 | * @param logger nestjs logger 53 | * @param eventRegistry register for event constructors 54 | */ 55 | createEventStore( 56 | options: EventStoreOptions, 57 | logger: Logger, 58 | eventRegistry: EventRegistry, 59 | ): Promise 60 | } 61 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent as INestEvent } from '@nestjs/cqrs' 2 | 3 | import { CustomEventOptions } from './custom-event-options' 4 | 5 | /** 6 | * Event interface 7 | * 8 | * In order for the event-store to replay your events implement the `Event` interface and create a constructor according 9 | * to [[EventConstructor]], or for conveniency extend the [[DefaultEvent]] class: 10 | * ```typescript 11 | * constructor( 12 | * public readonly aggregateId: string, 13 | * public readonly aggregateType: string, 14 | * public readonly revision: number, 15 | * public readonly createdAt: Date, 16 | * public readonly userId: string, 17 | * public readonly data: {[key: string]: any} 18 | * ) 19 | * ``` 20 | * It is recommended to define `data` as an interface with the event 21 | * 22 | * Example: 23 | * ```typescript 24 | * import { Event } from '@sclable/es-cqrs' 25 | * 26 | * interface EventData { 27 | * accountName: string, 28 | * email: string, 29 | * birthday: Date, 30 | * } 31 | * 32 | * export class AccountCreatedEvent implements Event { 33 | * public constructor( 34 | * public readonly aggregateId: string, 35 | * public readonly aggregateType: string, 36 | * public readonly revision: number, 37 | * public readonly createdAt: Date, 38 | * public readonly userId: string, 39 | * public readonly data: EventData, 40 | * ) {} 41 | * } 42 | * ``` 43 | * To use [[CustomEventOptions]] define that as the forth parameter 44 | * 45 | * Example: 46 | * ```typescript 47 | * @EventForModule('MyModule') 48 | * export class AccountCreatedEvent implements Event { 49 | * public constructor( 50 | * public readonly aggregateId: string, 51 | * public readonly aggregateType: string, 52 | * public readonly revision: number, 53 | * public readonly createdAt: Date, 54 | * public readonly userId: string, 55 | * public readonly data: EventData, 56 | * public readonly custom: CustomEventOptions, 57 | * ) {} 58 | * } 59 | * ``` 60 | * 61 | * Or use it as a computed parameter 62 | * 63 | * Example: 64 | * ```typescript 65 | * @EventForModule('MyModule') 66 | * export class AccountCreatedEvent implements Event { 67 | * public constructor( 68 | * public readonly aggregateId: string, 69 | * public readonly aggregateType: string, 70 | * public readonly revision: number, 71 | * public readonly createdAt: Date, 72 | * public readonly userId: string, 73 | * public readonly data: EventData, 74 | * public readonly custom: CustomEventOptions = { queueById: data.customId } 75 | * ) {} 76 | * } 77 | * ``` 78 | * 79 | * To handle these events implement the `IEventHandler` interface. 80 | * 81 | * Example: 82 | * 83 | * ```typescript 84 | * import { EventHandler, IEventHandler } from '@sclable/es-cqrs' 85 | * import { AccountCreatedEvent } from './events' 86 | * 87 | * @EventHandler(AccountCreatedEvent) 88 | * export class AccountCreatedEventHandler implements IEventHandler { 89 | * async handle(event: AccountCreatedEvent) { 90 | * // do something (e.g. change DB, change view, emit to other type of listeners) 91 | * } 92 | * } 93 | * ``` 94 | */ 95 | export interface Event extends INestEvent { 96 | aggregateId: string 97 | aggregateType: string 98 | revision: number 99 | createdAt: Date 100 | userId: string 101 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 102 | data: any 103 | customOptions?: CustomEventOptions 104 | } 105 | 106 | /** 107 | * Constructor signature used to reconstruct an [[Event]] instance from the event store 108 | */ 109 | export type EventConstructor = new ( 110 | aggregateId: string, 111 | aggregateType: string, 112 | revision: number, 113 | createdAt: Date, 114 | userId: string, 115 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 116 | data: any, 117 | customOptions?: CustomEventOptions, 118 | ) => Event 119 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | AsyncProvider, 3 | AsyncProviderFactory, 4 | createAsyncProvider, 5 | createAsyncProviders, 6 | } from './async-provider' 7 | export { Command } from './command' 8 | export { CustomEventOptions } from './custom-event-options' 9 | export { Event, EventConstructor } from './event' 10 | export { EventSourcedAggregate } from './event-sourced-aggregate' 11 | export { 12 | EVENT_STORE_PROVIDER, 13 | EventStoreProvider, 14 | EventStoreProviderFactory, 15 | } from './event-store-provider' 16 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/rate-limited-event-bus.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { EventBus, IEventHandler } from '@nestjs/cqrs' 3 | import pLimit, { Limit } from 'p-limit' 4 | 5 | import { Event } from './interfaces' 6 | 7 | /** 8 | * Rate-limited event bus that will put event handlers in a promise chain to avoid concurrency issues on the read side. 9 | * 10 | * This is mainly because insert and update events can come one after another in a rapid succession (especially during 11 | * replay) and the insert does not finish before the updates, and nothing is updated in the end. 12 | * 13 | * The rate-limiting is based on the aggregate ID. 14 | * 15 | * *Note*: If an event-handler throws an error, the rest of them will work fine. 16 | */ 17 | @Injectable() 18 | export class RateLimitedEventBus extends EventBus { 19 | private limits: { [key: string]: Limit } = {} 20 | 21 | public bind(handler: IEventHandler, id: string): void { 22 | const stream$ = id ? this.ofEventId(id) : this.subject$ 23 | stream$.subscribe(event => { 24 | if (event.customOptions && event.customOptions.skipQueue) { 25 | handler.handle(event) 26 | 27 | return 28 | } 29 | const id = 30 | event.customOptions && event.customOptions.queueById 31 | ? event.customOptions.queueById 32 | : event.aggregateId 33 | 34 | if (!this.limits[id]) { 35 | this.limits[id] = pLimit(1) 36 | } 37 | 38 | this.limits[id](() => handler.handle(event)) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/es-cqrs/src/repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { EventPublisher } from '@nestjs/cqrs' 3 | 4 | import { Aggregate, AggregateConstructor } from './aggregate' 5 | import { EVENTS_ON_AGGREGATE_METADATA } from './decorators' 6 | import { EventRegistry } from './event-store' 7 | import { Event, EventConstructor, EventStoreProvider } from './interfaces' 8 | 9 | /** @hidden */ 10 | export function getRepositoryToken( 11 | aggregate: AggregateConstructor, 12 | ): string { 13 | return `${aggregate.name}ESCQRSAggregateRepository` 14 | } 15 | 16 | /** 17 | * An aggregate repository is a storage handler for aggregates. It recreates an aggregate every time from the event 18 | * store, but to speed things up, it first tries to load up a snapshot of the aggregate. It is also responsible for 19 | * persisting the aggregate by saving its events. 20 | * 21 | * @typeParam T an [[Aggregate]] 22 | */ 23 | @Injectable() 24 | export class Repository { 25 | /** @hidden */ 26 | public constructor( 27 | private readonly aggregateType: AggregateConstructor, 28 | private readonly publisher: EventPublisher, 29 | private readonly eventStore: EventStoreProvider, 30 | eventRegistry: EventRegistry, 31 | ) { 32 | const events = Reflect.getMetadata(EVENTS_ON_AGGREGATE_METADATA, this.aggregateType) || [] 33 | events.forEach((event: EventConstructor) => eventRegistry.register(event.name, event)) 34 | } 35 | 36 | /** 37 | * Recreate an aggregate from the events 38 | * 39 | * @param id aggregate id 40 | * @param userId user id for subsequent operations, can be omitted for reading 41 | * @returns an aggregate 42 | */ 43 | public async find(id: string, userId?: string): Promise { 44 | const aggregate: T = new this.aggregateType(id, userId ?? 'no-user-specified') 45 | Object.assign(aggregate, await this.eventStore.getSnapshot(id)) 46 | aggregate.loadFromHistory(await this.eventStore.getEvents(id, aggregate.revision + 1)) 47 | 48 | return aggregate 49 | } 50 | 51 | /** 52 | * Persist an aggregate by saving its events 53 | */ 54 | public async persist(aggregate: T): Promise { 55 | await this.eventStore.saveEvents(aggregate) 56 | 57 | this.publisher.mergeObjectContext(aggregate) 58 | aggregate.commit() 59 | 60 | await this.eventStore.saveSnapshot(aggregate) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/es-cqrs/test/aggregate.spec.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid' 2 | 3 | import { Aggregate, DefaultEvent, EventConstructor, EventSourcableAggregate } from '../src' 4 | import { EVENTS_ON_AGGREGATE_METADATA } from '../src/decorators' 5 | 6 | class TestEvent extends DefaultEvent> {} 7 | 8 | const aggregateId1 = uuidv4() 9 | const userId1 = uuidv4() 10 | const revision1 = 1 11 | const date1 = new Date() 12 | const event1 = new TestEvent(aggregateId1, 'TestAggregate', revision1, date1, '', {}) 13 | 14 | @EventSourcableAggregate(TestEvent) 15 | class TestAggregate extends Aggregate { 16 | public onTestEvent = jest.fn() 17 | public publishAll = jest.fn() 18 | } 19 | 20 | class UnsourcedTestAggregate extends Aggregate {} 21 | 22 | const aggregate = new TestAggregate(aggregateId1, userId1) 23 | 24 | describe('Aggregate', () => { 25 | it('should be defined', () => { 26 | expect(aggregate).toBeDefined() 27 | }) 28 | 29 | it('should not get any uncommitted events', () => { 30 | expect(aggregate.getUncommittedEvents().length).toEqual(0) 31 | }) 32 | 33 | it('should store and handle applied events', () => { 34 | aggregate.apply(event1) 35 | expect(aggregate.getUncommittedEvents()).toEqual([event1]) 36 | expect(aggregate.onTestEvent).toBeCalledWith(event1) 37 | }) 38 | 39 | it('should publish and remove commited events', () => { 40 | aggregate.commit() 41 | // TODO investigate why the function is called with an empty array 42 | // expect(aggregate.publishAll).toBeCalledWith([event1]) 43 | expect(aggregate.publishAll).toBeCalled() 44 | expect(aggregate.getUncommittedEvents().length).toEqual(0) 45 | }) 46 | 47 | it('give an upped revision', () => { 48 | expect(aggregate.uppedRevision()).toEqual(revision1 + 1) 49 | }) 50 | 51 | it('should return list of possible events', () => { 52 | const testEvents: EventConstructor[] = Reflect.getMetadata( 53 | EVENTS_ON_AGGREGATE_METADATA, 54 | TestAggregate, 55 | ) 56 | expect(testEvents.length).toEqual(1) 57 | expect(new testEvents[0](aggregateId1, 'TestAggregate', revision1, date1, '', {})).toEqual( 58 | event1, 59 | ) 60 | }) 61 | 62 | it('should not have events if decorator is missing', () => { 63 | expect( 64 | Reflect.getMetadata(EVENTS_ON_AGGREGATE_METADATA, UnsourcedTestAggregate), 65 | ).toBeUndefined() 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /packages/es-cqrs/test/event-store/event-registry.spec.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid' 2 | 3 | import { DefaultEvent, EventConstructor, EventRegistry } from '../../src' 4 | 5 | const aggregateId1 = uuidv4() 6 | const revision1 = 1 7 | 8 | interface TestEventData { 9 | some: string 10 | } 11 | 12 | class TestEvent extends DefaultEvent {} 13 | 14 | const eventRegistry = new EventRegistry() 15 | const eventData: TestEventData = { some: 'value' } 16 | const eventDate = new Date() 17 | const event = new TestEvent(aggregateId1, 'TestAggregate', revision1, eventDate, '', eventData) 18 | 19 | describe('EventRegistry', () => { 20 | it('should be defined', () => { 21 | expect(eventRegistry).toBeDefined() 22 | }) 23 | 24 | it('should register and have an event constructor', () => { 25 | eventRegistry.register('TestEvent', TestEvent) 26 | const eventConstuctor = eventRegistry.get('TestEvent') 27 | expect(eventConstuctor).toBeDefined() 28 | expect( 29 | new eventConstuctor(aggregateId1, 'TestAggregate', revision1, eventDate, '', eventData), 30 | ).toEqual(event) 31 | }) 32 | 33 | it('should throw if event not found', () => { 34 | const testError = (): EventConstructor => eventRegistry.get('NonExistentEvent') 35 | expect(testError).toThrow('Unknown event type NonExistentEvent') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /packages/es-cqrs/test/event-store/replay.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { Test } from '@nestjs/testing' 3 | import { v4 as uuidv4 } from 'uuid' 4 | 5 | import { 6 | DefaultEvent, 7 | ESCQRSModule, 8 | Event, 9 | EventStoreOptions, 10 | EventStoreProvider, 11 | ReplayOptions, 12 | ReplayService, 13 | } from '../../src' 14 | import { RateLimitedEventBus as EventBus } from '../../src/rate-limited-event-bus' 15 | class MockedLogger extends Logger {} 16 | 17 | const events: Event[] = [] 18 | const mockedLogger = new MockedLogger() 19 | mockedLogger.log = jest.fn() 20 | const eventStoreOptions: EventStoreOptions = {} 21 | const mockEventStore: EventStoreProvider = { 22 | getEvents: () => Promise.resolve([]), 23 | getSnapshot: () => Promise.resolve({ id: '', revision: 1, userId: '' }), 24 | saveEvents: () => Promise.resolve(), 25 | saveSnapshot: () => Promise.resolve(), 26 | getReplay: jest.fn(async (op?: ReplayOptions) => { 27 | return op ? events.slice((op.fromPosition || 1) - 1, op.toPosition || 2 ** 32 - 1) : events 28 | }), 29 | getReplayCount: jest.fn(async () => events.length), 30 | init: () => Promise.resolve(), 31 | close: () => Promise.resolve(), 32 | } 33 | 34 | let service: ReplayService 35 | let eventBus: EventBus 36 | 37 | const MODULE_NAME = 'TestModule' 38 | 39 | class TestEvent1 extends DefaultEvent> {} 40 | 41 | describe('ReplayService', () => { 42 | beforeAll(async () => { 43 | const module = await Test.createTestingModule({ 44 | imports: [ESCQRSModule.forRoot({})], 45 | }).compile() 46 | eventBus = module.get(EventBus) 47 | eventBus.publish = jest.fn() 48 | service = new ReplayService(mockEventStore, eventBus, mockedLogger, eventStoreOptions) 49 | }) 50 | 51 | afterEach(() => { 52 | events.splice(0, events.length) 53 | jest.clearAllMocks() 54 | }) 55 | 56 | it('should be defined', () => { 57 | expect(service).toBeDefined() 58 | }) 59 | 60 | it('should only replay "ReplayFinished" event', async () => { 61 | await expect(service.replay()).resolves.toBe(0) 62 | expect(mockEventStore.getReplayCount).toBeCalled() 63 | expect(eventBus.publish).toBeCalled() 64 | }) 65 | 66 | it('should publish events', async () => { 67 | const event = new TestEvent1(uuidv4(), 'TestAggregate1', 1, new Date(), '', {}) 68 | events.push(event) 69 | await expect(service.replay()).resolves.toBe(1) 70 | expect(mockEventStore.getReplay).toBeCalled() 71 | expect(eventBus.publish).toBeCalledWith(event) 72 | }) 73 | 74 | it('should apply paging if too many events match the filter', async () => { 75 | const aggregateId = uuidv4() 76 | for (let i = 0; i < 10; i++) { 77 | events.push(new TestEvent1(aggregateId, 'TestAggregate1', i + 1, new Date(), '', {})) 78 | } 79 | await expect(service.replay({ pagingSize: 5 })).resolves.toBe(10) 80 | expect(mockEventStore.getReplay).toBeCalledTimes(2) 81 | expect(eventBus.publish).toBeCalledTimes(11) 82 | }) 83 | }) 84 | 85 | test('ReplayService - logging', async () => { 86 | eventStoreOptions.logging = true 87 | const loggingService = new ReplayService( 88 | mockEventStore, 89 | eventBus, 90 | mockedLogger, 91 | eventStoreOptions, 92 | ) 93 | 94 | expect(loggingService).toBeDefined() 95 | await expect(service.replay()).resolves.toBe(0) 96 | expect(mockedLogger.log).toBeCalled() 97 | }) 98 | -------------------------------------------------------------------------------- /packages/graphql-scalar-uuid/README.md: -------------------------------------------------------------------------------- 1 | # nestjs-graphql-scalar-uuid 2 | 3 | UUID validation for @nestjs/graphql 4 | 5 | ## Install 6 | 7 | ```shell 8 | npm install @sclable/nestjs-graphql-scalar-uuid 9 | ``` 10 | 11 | ## Usage 12 | 13 | Add scalar to graphql file 14 | 15 | ```graphql 16 | scalar UUIDv4 17 | ``` 18 | 19 | Add to providers 20 | 21 | ```typescript 22 | @Module({ 23 | ... 24 | providers: [UUIDv4Scalar] 25 | }) 26 | ``` 27 | -------------------------------------------------------------------------------- /packages/graphql-scalar-uuid/index.ts: -------------------------------------------------------------------------------- 1 | import { Scalar } from '@nestjs/graphql' 2 | 3 | import { UUIDScalar } from './uuid-scalar' 4 | 5 | @Scalar('UUIDv3') 6 | export class UUIDv3Scalar extends UUIDScalar { 7 | public description = 'UUID v3 custom scalar type' 8 | 9 | public constructor() { 10 | super('3') 11 | } 12 | } 13 | 14 | @Scalar('UUIDv4') 15 | export class UUIDv4Scalar extends UUIDScalar { 16 | public description = 'UUID v4 custom scalar type' 17 | 18 | public constructor() { 19 | super('4') 20 | } 21 | } 22 | 23 | @Scalar('UUIDv5') 24 | export class UUIDv5Scalar extends UUIDScalar { 25 | public description = 'UUID v5 custom scalar type' 26 | 27 | public constructor() { 28 | super('5') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/graphql-scalar-uuid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-graphql-scalar-uuid", 3 | "version": "2.0.3", 4 | "description": "UUID validation for @nestjs/graphql", 5 | "keywords": [ 6 | "nestjs", 7 | "graphql", 8 | "scalar", 9 | "uuid" 10 | ], 11 | "author": { 12 | "name": "Sclable Business Solutions GmbH", 13 | "email": "office@sclable.com", 14 | "url": "https://www.sclable.com/" 15 | }, 16 | "contributors": [ 17 | "Adam Koleszar " 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git@github.com:sclable/nestjs-libs.git" 22 | }, 23 | "license": "MIT", 24 | "publishConfig": { 25 | "registry": "https://registry.npmjs.org", 26 | "access": "public" 27 | }, 28 | "main": "dist/index.js", 29 | "types": "dist/index.d.ts", 30 | "files": [ 31 | "dist" 32 | ], 33 | "dependencies": { 34 | "@nestjs/class-validator": "^0.13.3", 35 | "graphql": "^16.8.1" 36 | }, 37 | "peerDependencies": { 38 | "@nestjs/graphql": ">=6.6.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/graphql-scalar-uuid/test/uuid-scalar.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver } from '@nestjs/apollo' 2 | import { INestApplication } from '@nestjs/common' 3 | import { Args, GraphQLModule, Query, Resolver } from '@nestjs/graphql' 4 | import { Test as NestTest, TestingModule } from '@nestjs/testing' 5 | import supertest from 'supertest' 6 | import { v4 as uuidv4 } from 'uuid' 7 | 8 | import { UUIDv4Scalar } from '..' 9 | 10 | const typeDefs = ` 11 | scalar UUIDv4 12 | type Data { 13 | id: ID! 14 | str: String 15 | } 16 | 17 | type Query { 18 | Data_getData(id: UUIDv4!): Data 19 | } 20 | ` 21 | const testData = 'TEST_DATA' 22 | 23 | interface Data { 24 | id: string 25 | str: string 26 | } 27 | 28 | let app: INestApplication 29 | let testServer: ReturnType 30 | 31 | @Resolver() 32 | class TestResolver { 33 | @Query('Data_getData') 34 | public async getData(@Args('id') id: string): Promise { 35 | return { 36 | id, 37 | str: testData, 38 | } 39 | } 40 | } 41 | 42 | describe('GraphQL UUID scalar', () => { 43 | beforeAll(async () => { 44 | const module: TestingModule = await NestTest.createTestingModule({ 45 | imports: [ 46 | GraphQLModule.forRoot({ driver: ApolloDriver, typeDefs, introspection: true }), 47 | ], 48 | providers: [UUIDv4Scalar, TestResolver], 49 | }).compile() 50 | app = module.createNestApplication() 51 | await app.init() 52 | testServer = supertest(app.getHttpServer()) 53 | }) 54 | afterAll(async () => { 55 | await app.close() 56 | }) 57 | test('valid UUIDv4', async () => { 58 | const id = uuidv4() 59 | await testServer 60 | .post('/graphql') 61 | .send({ query: `{Data_getData(id: "${id}") {id str}}` }) 62 | .expect(200, { 63 | data: { 64 | Data_getData: { 65 | id, 66 | str: testData, 67 | }, 68 | }, 69 | }) 70 | }) 71 | test('invalid UUIDv4', async () => { 72 | const id = 'invalid uuid' 73 | const response = await testServer 74 | .post('/graphql') 75 | .send({ query: `{Data_getData(id: "${id}") {id str}}` }) 76 | .expect(400) 77 | expect(response.body.errors[0]).toEqual( 78 | expect.objectContaining({ message: `Expected value of type "UUIDv4!", found "${id}".` }), 79 | ) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /packages/graphql-scalar-uuid/uuid-scalar.ts: -------------------------------------------------------------------------------- 1 | import { isUUID } from '@nestjs/class-validator' 2 | import { ASTNode, Kind } from 'graphql' 3 | 4 | type UUIDVersion = '3' | '4' | '5' 5 | 6 | export class UUIDScalar { 7 | protected constructor(private version: UUIDVersion) {} 8 | 9 | public parseValue(value: string): string { 10 | if (!isUUID(value, this.version)) { 11 | throw new TypeError(`UUID cannot represent non-UUID value: ${value}`) 12 | } 13 | 14 | return value.toLowerCase() 15 | } 16 | 17 | public serialize(value: string): string { 18 | return this.parseValue(value) 19 | } 20 | 21 | public parseLiteral(ast: ASTNode): string | undefined { 22 | if (ast.kind !== Kind.STRING || !isUUID(ast.value, this.version)) { 23 | return undefined 24 | } 25 | 26 | return ast.value 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/queue/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.1.8](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.7...@sclable/nestjs-queue@1.1.8) (2024-09-30) 7 | 8 | **Note:** Version bump only for package @sclable/nestjs-queue 9 | 10 | ## [1.1.7](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.6...@sclable/nestjs-queue@1.1.7) (2024-09-11) 11 | 12 | **Note:** Version bump only for package @sclable/nestjs-queue 13 | 14 | ## [1.1.6](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.5...@sclable/nestjs-queue@1.1.6) (2022-08-30) 15 | 16 | **Note:** Version bump only for package @sclable/nestjs-queue 17 | 18 | ## [1.1.5](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.4...@sclable/nestjs-queue@1.1.5) (2022-01-04) 19 | 20 | ### Bug Fixes 21 | 22 | - **queue:** upgrade azure-service-bus to v7.4.0 ([6bf7bab](https://github.com/sclable/nestjs-libs/commit/6bf7bababf1db1c31b81ed931380c6bcb90db3ec)) 23 | 24 | ## [1.1.4](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.3...@sclable/nestjs-queue@1.1.4) (2022-01-04) 25 | 26 | **Note:** Version bump only for package @sclable/nestjs-queue 27 | 28 | ## [1.1.3](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.2...@sclable/nestjs-queue@1.1.3) (2021-11-12) 29 | 30 | **Note:** Version bump only for package @sclable/nestjs-queue 31 | 32 | ## [1.1.2](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.1...@sclable/nestjs-queue@1.1.2) (2021-11-11) 33 | 34 | **Note:** Version bump only for package @sclable/nestjs-queue 35 | 36 | ## [1.1.1](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.1.0...@sclable/nestjs-queue@1.1.1) (2021-11-11) 37 | 38 | **Note:** Version bump only for package @sclable/nestjs-queue 39 | 40 | # [1.1.0](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.0.11...@sclable/nestjs-queue@1.1.0) (2021-11-10) 41 | 42 | ### Features 43 | 44 | - use common async provider library ([dc2f75f](https://github.com/sclable/nestjs-libs/commit/dc2f75f2e44b2aa283bbd3f3de20418604fb48fb)) 45 | 46 | ## [1.0.11](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.0.10...@sclable/nestjs-queue@1.0.11) (2021-11-09) 47 | 48 | **Note:** Version bump only for package @sclable/nestjs-queue 49 | 50 | ## [1.0.10](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.0.9...@sclable/nestjs-queue@1.0.10) (2021-11-04) 51 | 52 | **Note:** Version bump only for package @sclable/nestjs-queue 53 | 54 | ## [1.0.9](https://github.com/sclable/nestjs-libs/compare/@sclable/nestjs-queue@1.0.8...@sclable/nestjs-queue@1.0.9) (2021-10-27) 55 | 56 | **Note:** Version bump only for package @sclable/nestjs-queue 57 | 58 | ## 1.0.8 (2021-10-25) 59 | 60 | ### Bug Fixes 61 | 62 | - **auth:** enable /login endpoint ([1853202](https://github.com/sclable/nestjs-libs/commit/1853202630ae9219ec412c8cddf7b557435ee22a)) 63 | 64 | ## 1.0.7 (2021-10-25) 65 | 66 | **Note:** Version bump only for package @sclable/nestjs-queue 67 | 68 | ## 1.0.6 (2021-10-21) 69 | 70 | **Note:** Version bump only for package @sclable/nestjs-queue 71 | 72 | ## 1.0.5 (2021-10-18) 73 | 74 | **Note:** Version bump only for package @sclable/nestjs-queue 75 | 76 | ## 1.0.4 (2021-10-11) 77 | 78 | **Note:** Version bump only for package @sclable/nestjs-queue 79 | 80 | ## 1.0.3 (2021-10-08) 81 | 82 | **Note:** Version bump only for package @sclable/nestjs-queue 83 | 84 | ## 1.0.2 (2021-10-08) 85 | 86 | **Note:** Version bump only for package @sclable/nestjs-queue 87 | 88 | ## 1.0.2 (2021-06-11) 89 | 90 | **Note:** Version bump only for package @sclable/nestjs-queue 91 | 92 | ## 1.0.1 (2021-06-09) 93 | 94 | **Note:** Version bump only for package @sclable/nestjs-queue 95 | -------------------------------------------------------------------------------- /packages/queue/examples/config/queue.config.ts: -------------------------------------------------------------------------------- 1 | // Remove linting comments in real application! 2 | 3 | // @ts-ignore 4 | import { registerAs } from '@nestjs/config' 5 | import { QueueModuleOptions, QueueType } from '@sclable/nestjs-queue' 6 | 7 | // eslint-disable-next-line import/no-default-export 8 | export default registerAs( 9 | 'queue', 10 | (): QueueModuleOptions => ({ 11 | type: (process.env.QUEUE_TYPE || QueueType.DUMMY) as QueueType, 12 | config: { 13 | [QueueType.DUMMY]: { 14 | enabled: true, 15 | }, 16 | [QueueType.RABBITMQ]: { 17 | hostname: process.env.QUEUE_RABBITMQ_HOSTNAME || 'localhost', 18 | port: +(process.env.QUEUE_RABBITMQ_PORT || 5672), 19 | username: process.env.QUEUE_RABBITMQ_USERNAME || 'guest', 20 | password: process.env.QUEUE_RABBITMQ_PASSWORD || 'guest', 21 | }, 22 | [QueueType.AZURE_SERVICE_BUS]: { 23 | connectionString: 24 | process.env.QUEUE_AZURE_SERVICE_BUS_CONNECTION_STRING || 25 | 'define QUEUE_AZURE_SERVICE_BUS_CONNECTION_STRING', 26 | }, 27 | }, 28 | }), 29 | ) 30 | -------------------------------------------------------------------------------- /packages/queue/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src' 2 | -------------------------------------------------------------------------------- /packages/queue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-queue", 3 | "version": "1.1.8", 4 | "description": "Queue adapters for NestJS.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Sclable Business Solutions GmbH", 8 | "email": "support@sclable.com", 9 | "url": "https://sclable.com/" 10 | }, 11 | "contributors": [ 12 | "Norbert Lehotzky " 13 | ], 14 | "scripts": { 15 | "build": "rimraf dist && tsc -p tsconfig.build.json" 16 | }, 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "dependencies": { 20 | "@sclable/nestjs-async-provider": "^1.0.5", 21 | "reflect-metadata": "^0.2.2" 22 | }, 23 | "peerDependencies": { 24 | "@azure/service-bus": "^7.4.0", 25 | "@nestjs/common": ">=6.11.11", 26 | "amqp-ts": "^1.8.0", 27 | "rxjs": ">=6.6.7" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git@github.com:sclable/nestjs-libs.git" 32 | }, 33 | "publishConfig": { 34 | "registry": "https://registry.npmjs.org", 35 | "access": "public" 36 | }, 37 | "gitHead": "e4864ea0c0b3d5a45e983c64e5fdd22ad69de945" 38 | } 39 | -------------------------------------------------------------------------------- /packages/queue/src/adapters/azure-service-bus.adapter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://docs.microsoft.com/en-us/javascript/api/@azure/service-bus/?view=azure-node-latest 3 | */ 4 | import { 5 | ProcessErrorArgs, 6 | ServiceBusClient, 7 | ServiceBusReceivedMessage, 8 | } from '@azure/service-bus' 9 | import { Injectable, Logger } from '@nestjs/common' 10 | 11 | import { QueueServiceContract } from '../contracts' 12 | import { AzureServiceBusAdapterOptions } from '../interfaces/adapter-options' 13 | import { AzureServiceBusMessage, QueueMessage } from '../messages' 14 | 15 | @Injectable() 16 | export class AzureServiceBusAdapter implements QueueServiceContract { 17 | private serviceBusClient: ServiceBusClient 18 | private readonly logger: Logger = new Logger(AzureServiceBusAdapter.name) 19 | 20 | public constructor(options: AzureServiceBusAdapterOptions) { 21 | this.serviceBusClient = new ServiceBusClient(options.connectionString) 22 | } 23 | 24 | public async addConsumer( 25 | queueName: string, 26 | consumer: (msg: QueueMessage) => Promise | void, 27 | ): Promise { 28 | const receiver = this.serviceBusClient.createReceiver(queueName) 29 | 30 | const errorHandler = (error: Error): void => { 31 | this.logger.error( 32 | `Error occurred while receiving or processing messages on queue ${queueName.toUpperCase()}.${ 33 | error.message 34 | }`, 35 | ) 36 | } 37 | 38 | const messageHandler = async (message: ServiceBusReceivedMessage): Promise => { 39 | consumer(new AzureServiceBusMessage(message, receiver, this.logger)) 40 | } 41 | 42 | receiver.subscribe({ 43 | processMessage: messageHandler, 44 | processError: async (args: ProcessErrorArgs) => errorHandler(args.error), 45 | }) 46 | this.logger.log(`Consumer added to queue: ${queueName}`) 47 | } 48 | 49 | public async sendMessage( 50 | queueName: string, 51 | payload: PayloadType, 52 | ): Promise { 53 | return this.serviceBusClient.createSender(queueName).sendMessages({ body: payload }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/queue/src/adapters/dummy.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | 3 | import { QueueServiceContract } from '../contracts' 4 | 5 | export class DummyAdapter implements QueueServiceContract { 6 | private readonly logger: Logger = new Logger(DummyAdapter.name) 7 | 8 | public async addConsumer(queueName: string): Promise { 9 | this.logger.log(`Consumer added to queue: ${queueName}`) 10 | } 11 | 12 | public async sendMessage( 13 | queueName: string, 14 | payload: PayloadType, 15 | ): Promise { 16 | this.logger.log( 17 | `Message sent to queue: ${queueName} with payload: ${JSON.stringify(payload)}`, 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/queue/src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { AzureServiceBusAdapter } from './azure-service-bus.adapter' 2 | export { RabbitmqAdapter } from './rabbitmq.adapter' 3 | export { InmemoryAdapter } from './inmemory.adapter' 4 | -------------------------------------------------------------------------------- /packages/queue/src/adapters/inmemory.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common' 2 | import { Subject } from 'rxjs' 3 | 4 | import { QueueServiceContract } from '../contracts' 5 | import { InmemoryMessage } from '../messages' 6 | 7 | @Injectable() 8 | export class InmemoryAdapter implements QueueServiceContract { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | private queues: Record>> = {} 11 | private readonly logger: Logger = new Logger(InmemoryAdapter.name) 12 | 13 | public async sendMessage( 14 | queueName: string, 15 | payload: PayloadType, 16 | ): Promise { 17 | if (!this.queues[queueName]) { 18 | this.queues[queueName] = new Subject>() 19 | } 20 | this.queues[queueName].next(new InmemoryMessage(payload)) 21 | } 22 | 23 | public async addConsumer( 24 | queueName: string, 25 | consumer: (msg: InmemoryMessage) => Promise | void, 26 | ): Promise { 27 | if (!this.queues[queueName]) { 28 | this.queues[queueName] = new Subject>() 29 | } 30 | this.queues[queueName].subscribe(consumer) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/queue/src/adapters/rabbitmq.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common' 2 | import { Connection, Message } from 'amqp-ts' 3 | 4 | import { QueueServiceContract } from '../contracts' 5 | import { RabbitmqAdapterOptions } from '../interfaces/adapter-options' 6 | import { QueueMessage } from '../messages' 7 | 8 | @Injectable() 9 | export class RabbitmqAdapter implements QueueServiceContract { 10 | private connection: Connection 11 | private readonly logger: Logger = new Logger(RabbitmqAdapter.name) 12 | 13 | public constructor(options: RabbitmqAdapterOptions) { 14 | this.connection = new Connection( 15 | `amqp://${options.username}:${options.password}@${options.hostname}:${options.port}`, 16 | ) 17 | } 18 | 19 | public async sendMessage( 20 | queueName: string, 21 | payload: PayloadType, 22 | ): Promise { 23 | return this.connection.declareQueue(queueName).send(new Message(payload)) 24 | } 25 | 26 | public async addConsumer( 27 | queueName: string, 28 | consumer: (msg: QueueMessage) => Promise | void, 29 | ): Promise { 30 | this.connection 31 | .declareQueue(queueName) 32 | .activateConsumer(consumer) 33 | .then(() => { 34 | this.logger.log(`Consumer added to queue: ${queueName}`) 35 | }) 36 | .catch((error: Error) => { 37 | this.logger.error( 38 | `Consumer cannot be added to queue: ${queueName}, error: ${error.message}`, 39 | ) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/queue/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const QUEUE_SERVICE = 'QUEUE_SERVICE' 2 | export const QUEUE_MODULE_OPTIONS = 'QUEUE_MODULE_OPTIONS' 3 | -------------------------------------------------------------------------------- /packages/queue/src/contracts/index.ts: -------------------------------------------------------------------------------- 1 | export { QueueServiceContract } from './queue-service.contract' 2 | -------------------------------------------------------------------------------- /packages/queue/src/contracts/queue-service.contract.ts: -------------------------------------------------------------------------------- 1 | import { QueueMessage } from '../messages' 2 | 3 | export interface QueueServiceContract { 4 | sendMessage(queueName: string, payload: PayloadType): Promise 5 | addConsumer( 6 | queueName: string, 7 | consumer: (msg: QueueMessage) => Promise | void, 8 | ): Promise 9 | } 10 | -------------------------------------------------------------------------------- /packages/queue/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export { QueueType } from './queue-type.enum' 2 | -------------------------------------------------------------------------------- /packages/queue/src/enums/queue-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum QueueType { 2 | DUMMY = 'dummy', 3 | INMEMORY = 'inmemory', 4 | RABBITMQ = 'rabbitmq', 5 | AZURE_SERVICE_BUS = 'azure-service-bus', 6 | } 7 | -------------------------------------------------------------------------------- /packages/queue/src/index.ts: -------------------------------------------------------------------------------- 1 | export { QUEUE_SERVICE } from './constants' 2 | export { QueueServiceContract } from './contracts' 3 | export { QueueType } from './enums' 4 | export { QueueMessage } from './messages' 5 | export { QueueModuleOptions } from './interfaces' 6 | export { QueueModule } from './queue.module' 7 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/adapter-options/azure-service-bus-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AzureServiceBusAdapterOptions { 2 | connectionString: string 3 | } 4 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/adapter-options/dummy-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DummyAdapterOptions { 2 | enabled: boolean 3 | } 4 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/adapter-options/index.ts: -------------------------------------------------------------------------------- 1 | export { AzureServiceBusAdapterOptions } from './azure-service-bus-adapter-options.interface' 2 | export { DummyAdapterOptions } from './dummy-adapter-options.interface' 3 | export { RabbitmqAdapterOptions } from './rabbitmq-adapter-options.interface' 4 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/adapter-options/rabbitmq-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface RabbitmqAdapterOptions { 2 | hostname: string 3 | port: number 4 | username: string 5 | password: string 6 | } 7 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export { AzureServiceBusAdapterOptions, RabbitmqAdapterOptions } from './adapter-options' 2 | export { QueueModuleOptions } from './queue-module-options.interface' 3 | -------------------------------------------------------------------------------- /packages/queue/src/interfaces/queue-module-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { QueueType } from '../enums' 2 | import { 3 | AzureServiceBusAdapterOptions, 4 | DummyAdapterOptions, 5 | RabbitmqAdapterOptions, 6 | } from './adapter-options' 7 | 8 | export interface QueueModuleOptions { 9 | type: QueueType 10 | config: { 11 | [QueueType.DUMMY]?: DummyAdapterOptions 12 | [QueueType.INMEMORY]?: Record 13 | [QueueType.RABBITMQ]?: RabbitmqAdapterOptions 14 | [QueueType.AZURE_SERVICE_BUS]?: AzureServiceBusAdapterOptions 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/queue/src/messages/azure-service-bus.message.ts: -------------------------------------------------------------------------------- 1 | import { ServiceBusReceivedMessage, ServiceBusReceiver } from '@azure/service-bus' 2 | import { Logger } from '@nestjs/common' 3 | 4 | import { QueueMessage } from './queue.message' 5 | 6 | export interface AzureServiceBusMessagePayload { 7 | eventType?: string 8 | subject?: string 9 | } 10 | 11 | export class AzureServiceBusMessage implements QueueMessage { 12 | public constructor( 13 | private readonly original: ServiceBusReceivedMessage, 14 | private readonly receiver: ServiceBusReceiver, 15 | private readonly logger: Logger, 16 | ) {} 17 | 18 | public getContent(): PayloadType { 19 | return this.original.body as PayloadType 20 | } 21 | 22 | public ack(): void { 23 | this.receiver.completeMessage(this.original).catch(error => { 24 | this.logger.error(`AzureServiceBusMessage.ack() failed: ${JSON.stringify(error)}`) 25 | }) 26 | } 27 | 28 | public reject(requeue: boolean): void { 29 | if (requeue) { 30 | this.receiver.abandonMessage(this.original).catch(error => { 31 | this.logger.error(`AzureServiceBusMessage.reject() failed: ${JSON.stringify(error)}`) 32 | }) 33 | } else { 34 | this.receiver.deadLetterMessage(this.original).catch(error => { 35 | this.logger.error(`AzureServiceBusMessage.reject() failed: ${JSON.stringify(error)}`) 36 | }) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/queue/src/messages/index.ts: -------------------------------------------------------------------------------- 1 | export { AzureServiceBusMessage } from './azure-service-bus.message' 2 | export { InmemoryMessage } from './inmemory.message' 3 | export { QueueMessage } from './queue.message' 4 | export { RabbitmqMessage } from './rabbitmq.message' 5 | -------------------------------------------------------------------------------- /packages/queue/src/messages/inmemory.message.ts: -------------------------------------------------------------------------------- 1 | import { QueueMessage } from './queue.message' 2 | 3 | export class InmemoryMessage implements QueueMessage { 4 | public constructor(private readonly msg: T) {} 5 | public getContent(): T { 6 | return this.msg 7 | } 8 | public ack(): void { 9 | /* no-op */ 10 | } 11 | public reject(): void { 12 | /* no-op */ 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/queue/src/messages/queue.message.ts: -------------------------------------------------------------------------------- 1 | export interface QueueMessage { 2 | getContent(): PayloadType 3 | ack(): void 4 | reject(queue: boolean): void 5 | } 6 | -------------------------------------------------------------------------------- /packages/queue/src/messages/rabbitmq.message.ts: -------------------------------------------------------------------------------- 1 | import { Message as AmqpMessage } from 'amqp-ts' 2 | 3 | import { QueueMessage } from './queue.message' 4 | 5 | export class RabbitmqMessage 6 | extends AmqpMessage 7 | implements QueueMessage 8 | { 9 | public getContent(): PayloadType { 10 | return super.getContent() as PayloadType 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/queue/src/queue.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Logger, Module, Provider } from '@nestjs/common' 2 | import { AsyncProvider, createAsyncProviders } from '@sclable/nestjs-async-provider' 3 | 4 | import { AzureServiceBusAdapter, InmemoryAdapter, RabbitmqAdapter } from './adapters' 5 | import { DummyAdapter } from './adapters/dummy.adapter' 6 | import { QUEUE_MODULE_OPTIONS, QUEUE_SERVICE } from './constants' 7 | import { QueueServiceContract } from './contracts' 8 | import { QueueType } from './enums' 9 | import { 10 | AzureServiceBusAdapterOptions, 11 | QueueModuleOptions, 12 | RabbitmqAdapterOptions, 13 | } from './interfaces' 14 | 15 | @Global() 16 | @Module({}) 17 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 18 | export class QueueModule { 19 | public static forRoot(options: QueueModuleOptions): DynamicModule { 20 | const optionsProvider: Provider = { 21 | provide: QUEUE_MODULE_OPTIONS, 22 | useValue: options, 23 | } 24 | 25 | const asyncProviders = createAsyncProviders( 26 | { 27 | inject: [QUEUE_MODULE_OPTIONS], 28 | useFactory: createQueueService, 29 | }, 30 | QUEUE_SERVICE, 31 | ) 32 | 33 | return { 34 | module: QueueModule, 35 | exports: [optionsProvider, ...asyncProviders], 36 | providers: [optionsProvider, ...asyncProviders], 37 | } 38 | } 39 | 40 | public static forRootAsync(asyncOptions: AsyncProvider): DynamicModule { 41 | const asyncProviders = createAsyncProviders(asyncOptions, QUEUE_MODULE_OPTIONS) 42 | asyncProviders.push( 43 | ...createAsyncProviders( 44 | { 45 | inject: [QUEUE_MODULE_OPTIONS], 46 | useFactory: createQueueService, 47 | }, 48 | QUEUE_SERVICE, 49 | ), 50 | ) 51 | 52 | return { 53 | module: QueueModule, 54 | exports: [...asyncProviders], 55 | providers: [...asyncProviders], 56 | } 57 | } 58 | } 59 | 60 | function assertNever(object: never): never { 61 | throw new Error(`All cases must be handled here: ${object}`) 62 | } 63 | 64 | function createQueueService( 65 | options: QueueModuleOptions, 66 | logger: Logger = new Logger(QueueModule.name), 67 | ): QueueServiceContract { 68 | let qs: QueueServiceContract 69 | 70 | if (!options.config[options.type]) { 71 | throw Error(`${options.type.toUpperCase()} Queue Adapter is not configured`) 72 | } 73 | 74 | switch (options.type) { 75 | case QueueType.DUMMY: { 76 | if (!options.config[QueueType.DUMMY]?.enabled) { 77 | throw Error('Dummy Queue Adapter is disabled, enable it to use') 78 | } 79 | qs = new DummyAdapter() 80 | break 81 | } 82 | 83 | case QueueType.INMEMORY: { 84 | qs = new InmemoryAdapter() 85 | break 86 | } 87 | 88 | case QueueType.RABBITMQ: { 89 | qs = new RabbitmqAdapter(options.config[QueueType.RABBITMQ] as RabbitmqAdapterOptions) 90 | break 91 | } 92 | 93 | case QueueType.AZURE_SERVICE_BUS: { 94 | qs = new AzureServiceBusAdapter( 95 | options.config[QueueType.AZURE_SERVICE_BUS] as AzureServiceBusAdapterOptions, 96 | ) 97 | break 98 | } 99 | 100 | default: { 101 | assertNever(options.type) 102 | } 103 | } 104 | 105 | logger.log(`QueueService ${options.type.toUpperCase()} initialized`, QueueModule.name) 106 | 107 | return qs 108 | } 109 | -------------------------------------------------------------------------------- /packages/queue/test/dummy.adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing' 2 | 3 | import { 4 | QUEUE_SERVICE, 5 | QueueMessage, 6 | QueueModule, 7 | QueueModuleOptions, 8 | QueueServiceContract, 9 | QueueType, 10 | } from '../src' 11 | 12 | describe('Dummy adapter ', () => { 13 | let service: QueueServiceContract 14 | 15 | beforeAll(async () => { 16 | const testModule = await Test.createTestingModule({ 17 | imports: [ 18 | QueueModule.forRootAsync({ 19 | inject: [], 20 | useFactory: (): QueueModuleOptions => ({ 21 | type: QueueType.DUMMY, 22 | config: { 23 | [QueueType.DUMMY]: { 24 | enabled: true, 25 | }, 26 | }, 27 | }), 28 | }), 29 | ], 30 | }).compile() 31 | 32 | service = testModule.get(QUEUE_SERVICE) 33 | }) 34 | 35 | test('service defined', () => { 36 | expect(service).toBeDefined() 37 | }) 38 | 39 | test('listen and write', async () => { 40 | const message = 'Test message' 41 | await expect( 42 | service.addConsumer('test', (_msg: QueueMessage) => { 43 | /* no-op */ 44 | }), 45 | ).resolves.toBeUndefined() 46 | 47 | await expect(service.sendMessage('test', message)).resolves.toBeUndefined() 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/queue/test/inmemory.adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing' 2 | 3 | import { 4 | QUEUE_SERVICE, 5 | QueueMessage, 6 | QueueModule, 7 | QueueModuleOptions, 8 | QueueServiceContract, 9 | QueueType, 10 | } from '../src' 11 | 12 | describe('Inmemory adapter ', () => { 13 | let service: QueueServiceContract 14 | 15 | beforeAll(async () => { 16 | const testModule = await Test.createTestingModule({ 17 | imports: [ 18 | QueueModule.forRootAsync({ 19 | inject: [], 20 | useFactory: (): QueueModuleOptions => ({ 21 | type: QueueType.INMEMORY, 22 | config: { 23 | [QueueType.INMEMORY]: {}, 24 | }, 25 | }), 26 | }), 27 | ], 28 | }).compile() 29 | 30 | service = testModule.get(QUEUE_SERVICE) 31 | }) 32 | 33 | test('service defined', () => { 34 | expect(service).toBeDefined() 35 | }) 36 | 37 | test('listen and write', done => { 38 | const message = 'Test message' 39 | service.addConsumer('test', (msg: QueueMessage) => { 40 | expect(msg.getContent()).toBe(message) 41 | done() 42 | }) 43 | 44 | service.sendMessage('test', message) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /packages/storage/examples/config/storage.config.ts: -------------------------------------------------------------------------------- 1 | // Remove linting comments in real application! 2 | import path from 'path' 3 | 4 | // @ts-ignore 5 | import { registerAs } from '@nestjs/config' 6 | import { StorageModuleOptions, StorageType } from '@sclable/nestjs-storage' 7 | 8 | // eslint-disable-next-line import/no-default-export 9 | export default registerAs( 10 | 'storage', 11 | (): StorageModuleOptions => ({ 12 | defaultDriver: (process.env.STORAGE_DEFAULT_DRIVER || StorageType.DUMMY) as StorageType, 13 | config: { 14 | [StorageType.DUMMY]: { 15 | enabled: true, 16 | }, 17 | [StorageType.LOCAL]: { 18 | basePath: path.join( 19 | __dirname, 20 | '../../../..', 21 | process.env.STORAGE_LOCAL_BASE_PATH || 'storage/build', 22 | ), 23 | }, 24 | [StorageType.MINIO]: { 25 | endPoint: process.env.STORAGE_MINIO_ENDPOINT || 'localhost', 26 | port: +(process.env.STORAGE_MINIO_PORT || 9000), 27 | useSSL: process.env.STORAGE_MINIO_SSL === 'true', 28 | accessKey: process.env.STORAGE_MINIO_ACCESS_KEY || 'minio', 29 | secretKey: process.env.STORAGE_MINIO_SECRET_KEY || 'minio123', 30 | linkExpiryInSeconds: +(process.env.STORAGE_LINK_EXPIRY_IN_SECONDS || 60), 31 | }, 32 | [StorageType.AZURE]: { 33 | accountName: 34 | process.env.STORAGE_AZURE_ACCOUNT_NAME || 'define STORAGE_AZURE_ACCOUNT_NAME', 35 | accountKey: 36 | process.env.STORAGE_AZURE_ACCOUNT_KEY || 'define STORAGE_AZURE_ACCOUNT_KEY', 37 | linkExpiryInSeconds: +(process.env.STORAGE_LINK_EXPIRY_IN_SECONDS || 60), 38 | fileUploadedQueueName: 39 | process.env.STORAGE_AZURE_FILE_UPLOADED_QUEUE_NAME || 40 | 'define STORAGE_AZURE_FILE_UPLOADED_QUEUE_NAME', 41 | }, 42 | }, 43 | }), 44 | ) 45 | -------------------------------------------------------------------------------- /packages/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src' 2 | -------------------------------------------------------------------------------- /packages/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sclable/nestjs-storage", 3 | "version": "1.1.11", 4 | "description": "Storage adapters for NestJS.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Sclable Business Solutions GmbH", 8 | "email": "support@sclable.com", 9 | "url": "https://sclable.com/" 10 | }, 11 | "contributors": [ 12 | "Norbert Lehotzky " 13 | ], 14 | "scripts": { 15 | "build": "rimraf dist && tsc -p tsconfig.build.json" 16 | }, 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "dependencies": { 20 | "@sclable/nestjs-async-provider": "^1.0.5", 21 | "fs-extra": "^11.0.0", 22 | "reflect-metadata": "^0.2.2", 23 | "rimraf": "^6.0.1", 24 | "uuid": "^10.0.0" 25 | }, 26 | "peerDependencies": { 27 | "@azure/abort-controller": "^1.0.1", 28 | "@azure/storage-blob": "^12.1.1", 29 | "@nestjs/common": ">=6.11.11", 30 | "@sclable/nestjs-queue": "^1.0.11", 31 | "minio": "^7.0.14" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git@github.com:sclable/nestjs-libs.git" 36 | }, 37 | "publishConfig": { 38 | "registry": "https://registry.npmjs.org", 39 | "access": "public" 40 | }, 41 | "gitHead": "e4864ea0c0b3d5a45e983c64e5fdd22ad69de945" 42 | } 43 | -------------------------------------------------------------------------------- /packages/storage/src/adapters/abstract.adapter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extend this class to add more adapters 3 | */ 4 | export abstract class AbstractAdapter { 5 | protected buckets: string[] = [] 6 | 7 | protected async streamToBuffer(readableStream: NodeJS.ReadableStream): Promise { 8 | return new Promise((resolve, reject) => { 9 | const buffers: Buffer[] = [] 10 | 11 | readableStream.on('error', reject) 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | readableStream.on('data', (chunk: any) => buffers.push(chunk)) 14 | readableStream.on('end', () => resolve(Buffer.concat(buffers))) 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/storage/src/adapters/dummy-storage.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream' 2 | 3 | import { Logger } from '@nestjs/common' 4 | import { v4 as uuidV4 } from 'uuid' 5 | 6 | import { StorageDriverContract } from '../contracts' 7 | import { FileMetaData } from '../interfaces' 8 | 9 | import ReadableStream = NodeJS.ReadableStream 10 | 11 | export class DummyStorageAdapter implements StorageDriverContract { 12 | private readonly logger: Logger = new Logger(DummyStorageAdapter.name) 13 | public constructor() { 14 | this.logger.log('DUMMY Storage Disk initialized') 15 | } 16 | 17 | public async createBucket(bucket: string): Promise { 18 | this.logger.log(`Bucket created: ${bucket}`) 19 | 20 | return 21 | } 22 | 23 | public async putObject(bucket: string, id: string): Promise { 24 | const result = `random-etag-${uuidV4()}` 25 | 26 | this.logger.log(`Object created: ${bucket}/${id}, result: ${result}`) 27 | 28 | return result 29 | } 30 | 31 | public async getObjectStream(bucket: string, id: string): Promise { 32 | const result = `random-string-${uuidV4()}` 33 | 34 | this.logger.log(`Object returned: ${bucket}/${id}, result: ${result}`) 35 | 36 | const readable = new Readable() 37 | readable.push(result) 38 | readable.push(null) 39 | 40 | return readable 41 | } 42 | 43 | public async getObject(bucket: string, id: string): Promise { 44 | const result = `random-string-${uuidV4()}` 45 | 46 | this.logger.log(`Object returned: ${bucket}/${id}, result: ${result}`) 47 | 48 | return Buffer.from(result, 'utf8') 49 | } 50 | 51 | public async deleteObject(bucket: string, id: string): Promise { 52 | this.logger.log(`Object deleted: ${bucket}/${id}`) 53 | 54 | return true 55 | } 56 | 57 | public async getMetaData(bucket: string, id: string): Promise { 58 | const result = { 59 | name: uuidV4(), 60 | size: 9999, 61 | } 62 | 63 | this.logger.log( 64 | `Metadata returned for: ${bucket}/${id}, result: ${JSON.stringify(result)}`, 65 | ) 66 | 67 | return result 68 | } 69 | 70 | public async getDownloadUrl(bucket: string, id: string, filename: string): Promise { 71 | const result = `http://${uuidV4()}--${filename}` 72 | 73 | this.logger.log(`Download URL returned for ${bucket}/${id}, result: ${result}`) 74 | 75 | return result 76 | } 77 | 78 | public async getUploadUrl( 79 | bucket: string, 80 | id: string, 81 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 82 | onUploaded: (record: any) => void, 83 | ): Promise { 84 | const result = `http://${uuidV4()}` 85 | 86 | onUploaded({}) 87 | 88 | this.logger.log(`Upload URL returned for ${bucket}/${id}, result: ${result}`) 89 | 90 | return result 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/storage/src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { DummyStorageAdapter } from './dummy-storage.adapter' 2 | export { MinioStorageAdapter } from './minio-storage.adapter' 3 | export { AzureBlobStorageAdapter } from './azure-blob-storage.adapter' 4 | -------------------------------------------------------------------------------- /packages/storage/src/constants.ts: -------------------------------------------------------------------------------- 1 | /** @hidden */ 2 | export const STORAGE_MODULE_OPTIONS = 'STORAGE_MODULE_OPTIONS' 3 | /** @hidden */ 4 | export const QUEUE_SERVICE = 'QUEUE_SERVICE' 5 | -------------------------------------------------------------------------------- /packages/storage/src/contracts/index.ts: -------------------------------------------------------------------------------- 1 | export { StorageDriverContract } from './storage-driver.contract' 2 | -------------------------------------------------------------------------------- /packages/storage/src/contracts/storage-driver.contract.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream' 2 | 3 | import { FileMetaData } from '../interfaces' 4 | 5 | import ReadableStream = NodeJS.ReadableStream 6 | 7 | export interface StorageDriverContract { 8 | createBucket(bucket: string): Promise 9 | putObject( 10 | bucket: string, 11 | id: string, 12 | content: Buffer | Readable, 13 | metadata?: FileMetaData, 14 | ): Promise 15 | getObject(bucket: string, id: string): Promise 16 | getObjectStream(bucket: string, id: string): Promise 17 | deleteObject(bucket: string, id: string): Promise 18 | getMetaData(bucket: string, id: string): Promise 19 | getDownloadUrl(bucket: string, id: string, filename: string): Promise 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | getUploadUrl(bucket: string, id: string, onUploaded: (record: any) => void): Promise 22 | } 23 | -------------------------------------------------------------------------------- /packages/storage/src/enums/index.ts: -------------------------------------------------------------------------------- 1 | export { StorageType } from './storage-type.enum' 2 | -------------------------------------------------------------------------------- /packages/storage/src/enums/storage-type.enum.ts: -------------------------------------------------------------------------------- 1 | /** Supported storage types */ 2 | export enum StorageType { 3 | DUMMY = 'dummy', 4 | LOCAL = 'local', 5 | MINIO = 'minio', 6 | AZURE = 'azure-blob-storage', 7 | } 8 | -------------------------------------------------------------------------------- /packages/storage/src/index.ts: -------------------------------------------------------------------------------- 1 | export { StorageDriverContract } from './contracts' 2 | export { StorageType } from './enums' 3 | export { StorageManager } from './storage.manager' 4 | export { StorageModule } from './storage.module' 5 | export { StorageModuleOptions } from './interfaces' 6 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/file-meta-data.interface.ts: -------------------------------------------------------------------------------- 1 | export interface FileMetaData { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | [key: string]: any 4 | name: string 5 | size: number 6 | } 7 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export { FileMetaData } from './file-meta-data.interface' 2 | export { StorageModuleOptions } from './storage-module-options.interface' 3 | export { 4 | AzureBlobStorageAdapterOptions, 5 | LocalStorageAdapterOptions, 6 | MinioStorageAdapterOptions, 7 | } from './storage-adapter-options' 8 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/azure-blob-storage-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { StorageAdapterOptions } from './storage-adapter-options.interface' 2 | 3 | export interface AzureBlobStorageAdapterOptions extends StorageAdapterOptions { 4 | accountName: string 5 | accountKey: string 6 | linkExpiryInSeconds: number 7 | fileUploadedQueueName?: string 8 | } 9 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/dummy-storage-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DummyStorageAdapterOptions { 2 | enabled: boolean 3 | } 4 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/index.ts: -------------------------------------------------------------------------------- 1 | export { AzureBlobStorageAdapterOptions } from './azure-blob-storage-adapter-options.interface' 2 | export { DummyStorageAdapterOptions } from './dummy-storage-adapter-options.interface' 3 | export { LocalStorageAdapterOptions } from './local-storage-adapter-options.interface' 4 | export { MinioStorageAdapterOptions } from './minio-storage-adapter-options.interface' 5 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/local-storage-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { StorageAdapterOptions } from './storage-adapter-options.interface' 2 | 3 | export interface LocalStorageAdapterOptions extends StorageAdapterOptions { 4 | basePath: string 5 | } 6 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/minio-storage-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { ClientOptions } from 'minio' 2 | 3 | import { StorageAdapterOptions } from './storage-adapter-options.interface' 4 | 5 | export interface MinioStorageAdapterOptions extends StorageAdapterOptions, ClientOptions { 6 | linkExpiryInSeconds: number 7 | } 8 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-adapter-options/storage-adapter-options.interface.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface StorageAdapterOptions {} 3 | -------------------------------------------------------------------------------- /packages/storage/src/interfaces/storage-module-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { QueueServiceContract } from '@sclable/nestjs-queue' 2 | 3 | import { StorageType } from '../enums' 4 | import { 5 | AzureBlobStorageAdapterOptions, 6 | DummyStorageAdapterOptions, 7 | LocalStorageAdapterOptions, 8 | MinioStorageAdapterOptions, 9 | } from './storage-adapter-options' 10 | 11 | export interface StorageModuleOptions { 12 | /** 13 | * Default storage driver 14 | * Used for access without specifying storage driver 15 | */ 16 | defaultDriver: StorageType 17 | /** Optional queue service provider for storage events */ 18 | queueService?: QueueServiceContract 19 | /** Driver configurations */ 20 | config: { 21 | [StorageType.DUMMY]?: DummyStorageAdapterOptions 22 | [StorageType.LOCAL]?: LocalStorageAdapterOptions 23 | [StorageType.MINIO]?: MinioStorageAdapterOptions 24 | [StorageType.AZURE]?: AzureBlobStorageAdapterOptions 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/storage/src/storage.manager.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Optional } from '@nestjs/common' 2 | import { QueueServiceContract } from '@sclable/nestjs-queue' 3 | 4 | import { AzureBlobStorageAdapter, DummyStorageAdapter, MinioStorageAdapter } from './adapters' 5 | import { LocalStorageAdapter } from './adapters/local-storage.adapter' 6 | import { QUEUE_SERVICE, STORAGE_MODULE_OPTIONS } from './constants' 7 | import { StorageDriverContract } from './contracts' 8 | import { StorageType } from './enums' 9 | import { 10 | AzureBlobStorageAdapterOptions, 11 | LocalStorageAdapterOptions, 12 | MinioStorageAdapterOptions, 13 | StorageModuleOptions, 14 | } from './interfaces' 15 | 16 | /** 17 | * Storage Manager Service 18 | * 19 | * Inject it for 20 | */ 21 | 22 | @Injectable() 23 | export class StorageManager { 24 | private readonly disks: Map = new Map< 25 | StorageType, 26 | StorageDriverContract 27 | >() 28 | 29 | public constructor( 30 | @Inject(STORAGE_MODULE_OPTIONS) 31 | private readonly storageModuleOptions: StorageModuleOptions, 32 | @Optional() @Inject(QUEUE_SERVICE) private readonly queueService: QueueServiceContract, 33 | ) {} 34 | 35 | private static assertNever(object: never): never { 36 | throw new Error(`All cases must be handled here: ${object}`) 37 | } 38 | 39 | public disk(driver?: StorageType): StorageDriverContract { 40 | driver = driver || this.storageModuleOptions.defaultDriver 41 | 42 | if (!this.disks.has(driver)) { 43 | this.initDisk(driver) 44 | } 45 | 46 | const disk = this.disks.get(driver) 47 | if (!disk) { 48 | throw new Error(`${driver.toString} Storage Disk is not ready to use`) 49 | } 50 | 51 | return disk 52 | } 53 | 54 | private initDisk(driver: StorageType): void { 55 | if (!this.storageModuleOptions.config[driver]) { 56 | throw Error( 57 | `${driver.toUpperCase()} Storage Driver is not configured, disk cannot be created`, 58 | ) 59 | } 60 | 61 | switch (driver) { 62 | case StorageType.DUMMY: { 63 | if (!this.storageModuleOptions.config[StorageType.DUMMY]?.enabled) { 64 | throw Error('Dummy Storage Disk is disabled, but accessed.') 65 | } 66 | this.disks.set(StorageType.DUMMY, new DummyStorageAdapter()) 67 | break 68 | } 69 | 70 | case StorageType.LOCAL: { 71 | this.disks.set( 72 | StorageType.LOCAL, 73 | new LocalStorageAdapter( 74 | this.storageModuleOptions.config[StorageType.LOCAL] as LocalStorageAdapterOptions, 75 | ), 76 | ) 77 | break 78 | } 79 | 80 | case StorageType.MINIO: { 81 | this.disks.set( 82 | StorageType.MINIO, 83 | new MinioStorageAdapter( 84 | this.storageModuleOptions.config[StorageType.MINIO] as MinioStorageAdapterOptions, 85 | ), 86 | ) 87 | break 88 | } 89 | 90 | case StorageType.AZURE: { 91 | this.disks.set( 92 | StorageType.AZURE, 93 | new AzureBlobStorageAdapter( 94 | this.storageModuleOptions.config[ 95 | StorageType.AZURE 96 | ] as AzureBlobStorageAdapterOptions, 97 | this.queueService, 98 | ), 99 | ) 100 | break 101 | } 102 | 103 | default: { 104 | StorageManager.assertNever(driver) 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages/storage/src/storage.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module, Provider } from '@nestjs/common' 2 | import { AsyncProvider, createAsyncProviders } from '@sclable/nestjs-async-provider' 3 | import { QUEUE_SERVICE, QueueServiceContract } from '@sclable/nestjs-queue' 4 | 5 | import { STORAGE_MODULE_OPTIONS } from './constants' 6 | import { StorageModuleOptions } from './interfaces' 7 | import { StorageManager } from './storage.manager' 8 | 9 | /** 10 | * The main module 11 | * In the root module import `StorageModule.forRootAsync()`. The module only accepts async configuration options 12 | * so provide a factory for getting the configuration. 13 | * 14 | * Example: `app.module.ts` 15 | * ```typescript 16 | * import { Module } from '@nestjs/common' 17 | * import { ConfigService } from '@nestjs/config' 18 | * import { StorageModule, StorageModuleOptions, StorageType } from '@sclable/nestjs-storage' 19 | * 20 | * @Module({ 21 | * imports: [ 22 | * // ... 23 | * StorageModule.forRootAsync({ 24 | * useFactory: (config: ConfigService) => ({ 25 | * ...config.get('storage', { 26 | * defaultDriver: StorageType.DUMMY, 27 | * config: {}, 28 | * }), 29 | * }), 30 | * inject: [ConfigService], 31 | * }), 32 | * // ... 33 | * ], 34 | * }) 35 | * export class AppModule {} 36 | * ``` 37 | */ 38 | @Global() 39 | @Module({}) 40 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 41 | export class StorageModule { 42 | public static forRoot(options: StorageModuleOptions): DynamicModule { 43 | const optionsProvider: Provider = { 44 | provide: STORAGE_MODULE_OPTIONS, 45 | useValue: options, 46 | } 47 | 48 | return { 49 | module: StorageModule, 50 | providers: [StorageManager, optionsProvider, this.createQueueServiceProvider()], 51 | } 52 | } 53 | 54 | public static forRootAsync( 55 | asyncOptions: AsyncProvider, 56 | ): DynamicModule { 57 | const asyncProviders = createAsyncProviders(asyncOptions, STORAGE_MODULE_OPTIONS) 58 | 59 | return { 60 | module: StorageModule, 61 | providers: [StorageManager, this.createQueueServiceProvider(), ...asyncProviders], 62 | exports: [StorageManager, ...asyncProviders], 63 | } 64 | } 65 | 66 | private static createQueueServiceProvider(): Provider { 67 | return { 68 | inject: [STORAGE_MODULE_OPTIONS], 69 | provide: QUEUE_SERVICE, 70 | useFactory: (options: StorageModuleOptions) => options.queueService, 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/storage/test/dummy-storage.adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { StorageManager, StorageModule, StorageType } from '../src' 5 | 6 | describe('Dummy adapter ', () => { 7 | let storage: StorageManager 8 | const TEST_BUCKET = 'test-bucket' 9 | const objectId = uuidv4() 10 | 11 | beforeAll(async () => { 12 | const testModule = await Test.createTestingModule({ 13 | imports: [ 14 | StorageModule.forRoot({ 15 | defaultDriver: StorageType.DUMMY, 16 | config: { 17 | [StorageType.DUMMY]: { 18 | enabled: true, 19 | }, 20 | }, 21 | }), 22 | ], 23 | }).compile() 24 | 25 | storage = testModule.get(StorageManager) 26 | }) 27 | 28 | test('default storage defined', () => { 29 | expect(storage).toBeDefined() 30 | expect(storage.disk()).toBeDefined() 31 | expect(storage.disk(StorageType.DUMMY)).toBeDefined() 32 | }) 33 | 34 | test('putObject', async () => { 35 | await expect( 36 | storage.disk().putObject(TEST_BUCKET, objectId, Buffer.from('data')), 37 | ).resolves.toMatch(/random-etag-.*/) 38 | }) 39 | 40 | test('getObject', async () => { 41 | const obj = await storage.disk().getObject(TEST_BUCKET, objectId) 42 | expect(obj).toBeInstanceOf(Buffer) 43 | expect(obj.toString()).toMatch(/random-string-.*/) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/storage/test/local-storage.adapter.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | import { StorageManager, StorageModule, StorageType } from '../src' 5 | 6 | describe('Local storage adapter ', () => { 7 | let storage: StorageManager 8 | const TEST_BUCKET = 'test-bucket' 9 | const objectId = uuidv4() 10 | const objectData = 'Test object data' 11 | const uuidv4Regex = new RegExp( 12 | /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, 13 | ) 14 | 15 | beforeAll(async () => { 16 | const testModule = await Test.createTestingModule({ 17 | imports: [ 18 | StorageModule.forRoot({ 19 | defaultDriver: StorageType.LOCAL, 20 | config: { 21 | [StorageType.LOCAL]: { 22 | basePath: '.', 23 | }, 24 | }, 25 | }), 26 | ], 27 | }).compile() 28 | 29 | storage = testModule.get(StorageManager) 30 | }) 31 | 32 | test('default storage defined', () => { 33 | expect(storage).toBeDefined() 34 | expect(storage.disk()).toBeDefined() 35 | expect(storage.disk(StorageType.LOCAL)).toBeDefined() 36 | }) 37 | 38 | test('putObject', async () => { 39 | await expect( 40 | storage.disk().putObject(TEST_BUCKET, objectId, Buffer.from(objectData)), 41 | ).resolves.toMatch(uuidv4Regex) 42 | }) 43 | 44 | test('getObject', async () => { 45 | const obj = await storage.disk().getObject(TEST_BUCKET, objectId) 46 | expect(obj).toBeInstanceOf(Buffer) 47 | expect(obj.toString()).toMatch(objectData) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "**/*.spec.ts", 4 | "packages/es-cqrs-schematics/**/templates/**/*.ts" 5 | ], 6 | "extends": "./tsconfig.json" 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "CommonJS", 5 | "lib": [ "es2018" ], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | // We're using ESLint to check for unused locals, it is more flexible. 14 | "noUnusedLocals": false, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "moduleResolution": "node", 18 | "baseUrl": ".", 19 | "paths": {"*": ["packages/*"]}, 20 | "esModuleInterop": true, 21 | "experimentalDecorators": true, 22 | "incremental": true 23 | }, 24 | "exclude": [ 25 | "node_modules", 26 | "packages/*/node_modules", 27 | "dist", 28 | "packages/*/dist", 29 | "packages/es-cqrs-schematics/**/templates/**/*.ts" 30 | ], 31 | "include": [ "packages/**/*.ts" ] 32 | } 33 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin": [ 3 | "typedoc-plugin-markdown", 4 | "typedoc-github-wiki-theme" 5 | ], 6 | "out": "docs", 7 | "tsconfig": "./tsconfig.json", 8 | "entryPoints": [ 9 | "packages/async-provider/index.ts", 10 | "packages/auth/index.ts", 11 | "packages/es-cqrs/index.ts", 12 | "packages/queue/index.ts", 13 | "packages/storage/index.ts" 14 | ], 15 | "exclude": ["**/*.spec.ts"], 16 | "excludeExternals": true, 17 | "readme": "none" 18 | } 19 | --------------------------------------------------------------------------------