├── .all-contributorsrc ├── .editorconfig ├── .github ├── renovate.json └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── angular.json ├── apps ├── .gitkeep ├── sandbox-api │ ├── jest.config.js │ ├── src │ │ ├── app │ │ │ ├── .gitkeep │ │ │ ├── get-bike.ts │ │ │ ├── get-bikes.ts │ │ │ ├── throw-server-error.ts │ │ │ └── token.ts │ │ ├── assets │ │ │ ├── .gitkeep │ │ │ └── bikes.json │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── sandbox-e2e │ ├── cypress.json │ ├── src │ │ ├── fixtures │ │ │ └── example.json │ │ ├── integration │ │ │ └── signin.spec.ts │ │ ├── plugins │ │ │ └── index.js │ │ └── support │ │ │ ├── commands.ts │ │ │ ├── index.ts │ │ │ └── signin.po.ts │ ├── tsconfig.e2e.json │ ├── tsconfig.json │ └── tslint.json └── sandbox │ ├── .browserslistrc │ ├── README.md │ ├── jest.config.js │ ├── src │ ├── app │ │ ├── app-routing.module.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── auth │ │ │ ├── auth.service.ts │ │ │ ├── is-authenticated.guard.ts │ │ │ └── is-not-authenticated.guard.ts │ │ ├── bike-detail │ │ │ └── bike-detail.component.ts │ │ ├── bike-search │ │ │ └── bike-search.component.ts │ │ ├── bike │ │ │ ├── README.md │ │ │ ├── bike-card.component.ts │ │ │ └── bike.ts │ │ ├── http │ │ │ ├── create-logger-plugin.ts │ │ │ ├── http.module.ts │ │ │ ├── reject-unknown-origins-plugin.spec.ts │ │ │ └── reject-unknown-origins-plugin.ts │ │ ├── nav │ │ │ └── nav.component.ts │ │ ├── retry │ │ │ └── retry.component.ts │ │ └── signin │ │ │ └── signin.component.ts │ ├── assets │ │ ├── .gitkeep │ │ ├── logo.svg │ │ └── verified_user.svg │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ ├── styles.css │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── tslint.json ├── codecov.yml ├── commitlint.config.js ├── docs └── custom-plugin.md ├── jest.config.js ├── lerna.json ├── libs ├── .gitkeep ├── angular │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── convoyr.inteceptor.spec.ts │ │ │ ├── convoyr.interceptor.ts │ │ │ ├── convoyr.module.spec.ts │ │ │ ├── convoyr.module.ts │ │ │ ├── http-converter.spec.ts │ │ │ └── http-converter.ts │ │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── convoyr.spec.ts │ │ │ ├── convoyr.ts │ │ │ ├── handler.ts │ │ │ ├── headers.ts │ │ │ ├── matchers │ │ │ │ ├── combiners │ │ │ │ │ ├── and.spec.ts │ │ │ │ │ ├── and.ts │ │ │ │ │ ├── not.spec.ts │ │ │ │ │ ├── not.ts │ │ │ │ │ ├── or.spec.ts │ │ │ │ │ └── or.ts │ │ │ │ ├── find-matcher-or-throw.ts │ │ │ │ ├── index.ts │ │ │ │ ├── match-method │ │ │ │ │ ├── invalid-method-match-expression.ts │ │ │ │ │ ├── match-method-expression.ts │ │ │ │ │ ├── match-method.spec.ts │ │ │ │ │ ├── match-method.ts │ │ │ │ │ ├── method-array-matcher.ts │ │ │ │ │ └── method-string-matcher.ts │ │ │ │ ├── match-origin │ │ │ │ │ ├── get-origin.spec.ts │ │ │ │ │ ├── get-origin.ts │ │ │ │ │ ├── invalid-origin-match-expression.ts │ │ │ │ │ ├── match-origin.spec.ts │ │ │ │ │ ├── match-origin.ts │ │ │ │ │ ├── origin-array-matcher.ts │ │ │ │ │ ├── origin-match-expression.ts │ │ │ │ │ ├── origin-predicate-matcher.ts │ │ │ │ │ ├── origin-reg-exp-matcher.ts │ │ │ │ │ └── origin-string-matcher.ts │ │ │ │ ├── match-path │ │ │ │ │ ├── invalid-path-match-expression.ts │ │ │ │ │ ├── match-path-expression.ts │ │ │ │ │ ├── match-path.spec.ts │ │ │ │ │ ├── match-path.ts │ │ │ │ │ └── method-string-matcher.ts │ │ │ │ ├── match-response-type │ │ │ │ │ ├── invalid-response-type-match-expression.ts │ │ │ │ │ ├── match-response-type-expression.ts │ │ │ │ │ ├── match-response-type.spec.ts │ │ │ │ │ ├── match-response-type.ts │ │ │ │ │ ├── response-type-array-matcher.ts │ │ │ │ │ └── response-type-string-matcher.ts │ │ │ │ └── matcher.ts │ │ │ ├── plugin.ts │ │ │ ├── request-handler.ts │ │ │ ├── request.ts │ │ │ ├── response.ts │ │ │ ├── throw-invalid-plugin-condition.ts │ │ │ └── utils │ │ │ │ ├── from-sync-or-async.spec.ts │ │ │ │ ├── from-sync-or-async.ts │ │ │ │ ├── is-array.ts │ │ │ │ ├── is-boolean.ts │ │ │ │ ├── is-function.ts │ │ │ │ ├── is-promise.ts │ │ │ │ ├── is-string.ts │ │ │ │ └── is-typeof.ts │ │ └── test-setup.ts │ ├── testing │ │ ├── ng-package.json │ │ └── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── create-plugin-tester.spec.ts │ │ │ ├── create-plugin-tester.ts │ │ │ ├── create-spy-plugin.spec.ts │ │ │ └── create-spy-plugin.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── plugin-auth │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── auth-handler.spec.ts │ │ │ ├── auth-handler.ts │ │ │ ├── create-auth-plugin-params.spec.ts │ │ │ ├── create-auth-plugin.ts │ │ │ ├── on-unauthorized.ts │ │ │ └── set-header.ts │ │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── plugin-cache │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── cache-entry.spec.ts │ │ │ ├── cache-entry.ts │ │ │ ├── cache-handler.spec.ts │ │ │ ├── cache-handler.ts │ │ │ ├── cache-metadata.ts │ │ │ ├── cache-response.ts │ │ │ ├── create-cache-plugin-params.ts │ │ │ ├── create-cache-plugin.ts │ │ │ └── storages │ │ │ │ ├── memory-storage.spec.ts │ │ │ │ ├── memory-storage.ts │ │ │ │ └── storage.ts │ │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json └── plugin-retry │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── index.spec.ts │ ├── index.ts │ ├── lib │ │ ├── create-retry-plugin-params.spec.ts │ │ ├── create-retry-plugin.spec.ts │ │ ├── create-retry-plugin.ts │ │ ├── predicates │ │ │ ├── is-server-error.spec.ts │ │ │ ├── is-server-error.ts │ │ │ ├── is-server-or-unknown-error.spec.ts │ │ │ ├── is-server-or-unknown-error.ts │ │ │ ├── is-unknown-error.spec.ts │ │ │ ├── is-unknown-error.ts │ │ │ └── retry-predicate.ts │ │ └── retry-handler.ts │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── logo.png ├── nx.json ├── package.json ├── tools ├── limbo.sh ├── schematics │ └── .gitkeep └── tsconfig.tools.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "convoyr", 3 | "projectOwner": "jscutlery", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "pegaltier", 15 | "name": "Pierre-Edouard Galtier", 16 | "avatar_url": "https://avatars0.githubusercontent.com/u/2479323?v=4", 17 | "profile": "https://www.it-dir.co", 18 | "contributions": [ 19 | "doc" 20 | ] 21 | }, 22 | { 23 | "login": "yjaaidi", 24 | "name": "Younes Jaaidi", 25 | "avatar_url": "https://avatars2.githubusercontent.com/u/2674658?v=4", 26 | "profile": "https://marmicode.io/", 27 | "contributions": [ 28 | "bug", 29 | "code", 30 | "doc", 31 | "example", 32 | "ideas" 33 | ] 34 | }, 35 | { 36 | "login": "edbzn", 37 | "name": "Edouard Bozon", 38 | "avatar_url": "https://avatars0.githubusercontent.com/u/8522558?v=4", 39 | "profile": "https://www.codamit.dev/", 40 | "contributions": [ 41 | "bug", 42 | "code", 43 | "doc", 44 | "example", 45 | "ideas" 46 | ] 47 | } 48 | ], 49 | "contributorsPerLine": 7 50 | } 51 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/renovate", 3 | "extends": ["config:base", ":semanticCommitScopeDisabled"], 4 | "automerge": true, 5 | "automergeType": "branch", 6 | "baseBranches": ["master"], 7 | "semanticCommits": true, 8 | "lockFileMaintenance": { 9 | "enabled": true, 10 | "automerge": true, 11 | "automergeType": "branch", 12 | "schedule": ["before 5am"] 13 | }, 14 | "rangeStrategy": "update-lockfile", 15 | "commitMessage": "{{{commitMessagePrefix}}} 📦 {{{commitMessageAction}}} {{{commitMessageTopic}}} {{{commitMessageExtra}}} {{{commitMessageSuffix}}}" 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - uses: actions/setup-node@v1 12 | 13 | - name: Cache node_modules 14 | uses: actions/cache@v2 15 | with: 16 | path: node_modules 17 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 18 | restore-keys: | 19 | ${{ runner.OS }}-build-${{ env.cache-name }}- 20 | ${{ runner.OS }}-build- 21 | ${{ runner.OS }}- 22 | 23 | - name: Install 24 | run: | 25 | yarn install --frozen-lockfile 26 | 27 | - name: Build 28 | run: | 29 | yarn nx affected:build --with-deps --prod --all 30 | 31 | e2e: 32 | runs-on: ubuntu-latest 33 | container: cypress/browsers:node13.6.0-chrome80-ff72 34 | env: 35 | CYPRESS_CACHE_FOLDER: .cypress-cache 36 | steps: 37 | - uses: actions/checkout@v2 38 | 39 | - uses: actions/setup-node@v1 40 | 41 | - name: Cache node_modules 42 | uses: actions/cache@v2 43 | with: 44 | path: | 45 | node_modules 46 | .cypress-cache 47 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 48 | restore-keys: | 49 | ${{ runner.OS }}-build-${{ env.cache-name }}- 50 | ${{ runner.OS }}-build- 51 | ${{ runner.OS }}- 52 | 53 | - name: Install 54 | run: | 55 | yarn install --frozen-lockfile 56 | 57 | - name: E2E 58 | run: | 59 | yarn run-p --race "start:api" "e2e --headless" 60 | 61 | test: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v2 65 | 66 | - uses: actions/setup-node@v1 67 | 68 | - name: Cache node_modules 69 | uses: actions/cache@v2 70 | with: 71 | path: node_modules 72 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 73 | restore-keys: | 74 | ${{ runner.OS }}-build-${{ env.cache-name }}- 75 | ${{ runner.OS }}-build- 76 | ${{ runner.OS }}- 77 | 78 | - name: Install 79 | run: | 80 | yarn install --frozen-lockfile 81 | 82 | - name: Test 83 | run: | 84 | yarn test --code-coverage 85 | 86 | - name: Upload coverage to Codecov 87 | uses: codecov/codecov-action@v2.1.0 88 | with: 89 | token: ${{secrets.CODECOV_TOKEN}} 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | lerna-debug.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "name": "plugin-cache", 7 | "request": "launch", 8 | "args": [ 9 | "--runInBand", 10 | "--config", 11 | "libs/plugin-cache/jest.config.js" 12 | ], 13 | "cwd": "${workspaceFolder}", 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "disableOptimisticBPs": true, 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Commit messages convention 2 | 3 | The JSCutlery team develops using techniques like Timeboxed TDD and TCR (Test && Commit || Revert). In other words, we commit a lot!!! 4 | 5 | We use emojis for commits categorization: 6 | 7 | | Type | Example | When | 8 | | ---------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | 9 | | Work In Progress | `wip(plugin-cache): 🚧 do craziness` | This is the commit message when working on a feature. Same message can be reused while working on the feature. | 10 | | Feature | `feat(plugin-cache): ✅ add craziness` | This is the final commit when the feature is finished and you want it to appear in the changelog. It can be an empty commit. | 11 | | Fix | `fix(plugin-cache): 🐞 fix craziness` | Anything that fixes a user facing bug. | 12 | | Anything else | `chore(plugin-cache): 🛠 rename stuff` | This applies to all changes that don't bring new features or fix user facing bugs. | 13 | | Docs | `docs(plugin-cache): 📝 add docs` | Documentation | 14 | 15 | - Each commit type can be scoped by package name like `feat(core,plugin-cache):`, or no scope at all _(i.e. `feat:`)_ if it affects everything 16 | 17 | - The `wip` type is allowed to be compliant with TCR 18 | 19 | - Breaking changes should add `!` or a `BREAKING CHANGE: ...` line in the body of the commit message with the explanation next to it. 20 | 21 | Cf. https://www.conventionalcommits.org/ 22 | 23 | # Add a new plugin 24 | 25 | Meanwhile we provide a schematic for this, here are the steps to follow when adding a new plugin: 26 | 27 | 1. Generate library 28 | 29 | ```sh 30 | yarn nx g @nrwl/workspace:library --publishable plugin-xyz 31 | ``` 32 | 33 | 2. Update `libs/plugin-xyz/package.json` 34 | 35 | ```json 36 | { 37 | "name": "@convoyr/plugin-xyz", 38 | "version": "2.1.0", 39 | "license": "MIT", 40 | "private": false, 41 | "repository": "git@github.com:jscutlery/convoyr.git", 42 | "scripts": { 43 | "prepublishOnly": "ng build plugin-xyz --prod" 44 | }, 45 | "peerDependencies": { 46 | "@convoyr/core": ">= 3.0.0" 47 | } 48 | } 49 | ``` 50 | 51 | 3. Update `libs/plugin-xyz/ng-package.json` 52 | 53 | ``` 54 | { 55 | ..., 56 | "dest": "dist", 57 | ... 58 | } 59 | ``` 60 | 61 | 4. Codecov setup by adding the following to codecov.yml 62 | 63 | ```yaml 64 | coverage: 65 | status: 66 | project: 67 | plugin-xyz: 68 | target: 90% 69 | flags: plugin-xyz 70 | flags: 71 | plugin-xyz: 72 | paths: 73 | - libs/plugin-xyz/src 74 | ``` 75 | 76 | 🚧 Work In Progress 🚧 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 jscutlery 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 | -------------------------------------------------------------------------------- /apps/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/sandbox-api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'sandbox-api', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/apps/sandbox-api' 5 | }; 6 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/apps/sandbox-api/src/app/.gitkeep -------------------------------------------------------------------------------- /apps/sandbox-api/src/app/get-bike.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { readFileSync } from 'fs'; 3 | import { Request, Response } from 'express'; 4 | 5 | export function getBike(req: Request, res: Response) { 6 | try { 7 | assert(req.headers.authorization === 'Bearer ABCDE'); 8 | } catch { 9 | return res.status(401).json({ message: 'Unauthorized' }); 10 | } 11 | 12 | const rawBikes = readFileSync(__dirname + '/assets/bikes.json', 'utf8'); 13 | const { bikes } = JSON.parse(rawBikes); 14 | const bike = bikes.find((_bike) => _bike.id === req.params.bikeId); 15 | 16 | if (bike == null) { 17 | return res.sendStatus(404); 18 | } 19 | 20 | res.json(bike); 21 | } 22 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/app/get-bikes.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { readFileSync } from 'fs'; 3 | import { Request, Response } from 'express'; 4 | 5 | export function getBikes(req: Request, res: Response) { 6 | try { 7 | assert(req.headers.authorization === 'Bearer ABCDE'); 8 | } catch { 9 | return res.status(401).json({ message: 'Unauthorized' }); 10 | } 11 | 12 | const rawBikes = readFileSync(__dirname + '/assets/bikes.json', 'utf8'); 13 | const { bikes } = JSON.parse(rawBikes); 14 | const query = req.query.q; 15 | 16 | if (typeof query === 'string') { 17 | return res.json({ 18 | bikes: bikes.filter(({ name }) => 19 | name.toLowerCase().includes(query.toLowerCase()) 20 | ), 21 | }); 22 | } 23 | 24 | res.json({ bikes }); 25 | } 26 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/app/throw-server-error.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export function throwServerError(req: Request, res: Response) { 4 | res.status(500).json({ message: 'Internal Server Error' }); 5 | } 6 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/app/token.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { Request, Response } from 'express'; 3 | 4 | export function createToken(req: Request, res: Response) { 5 | const { login, password } = req.body; 6 | 7 | try { 8 | assert(typeof login === 'string'); 9 | assert(typeof password === 'string'); 10 | } catch (error) { 11 | return res.status(403).json({ message: error }); 12 | } 13 | 14 | if (login !== 'demo' || password !== 'demo') { 15 | return res.status(403).json({ message: 'Invalid login or password' }); 16 | } 17 | 18 | res.json({ token: 'ABCDE' }); 19 | } 20 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/apps/sandbox-api/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/sandbox-api/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false 3 | }; 4 | -------------------------------------------------------------------------------- /apps/sandbox-api/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as cors from 'cors'; 3 | import * as bodyParser from 'body-parser'; 4 | 5 | import { getBikes } from './app/get-bikes'; 6 | import { getBike } from './app/get-bike'; 7 | import { throwServerError } from './app/throw-server-error'; 8 | import { createToken } from './app/token'; 9 | 10 | const app = express(); 11 | 12 | app.use(cors()); 13 | app.use(bodyParser.json()); 14 | 15 | app.get('/api/bikes', getBikes); 16 | app.get('/api/bikes/:bikeId', getBike); 17 | app.get('/api/server-error', throwServerError); 18 | app.post('/api/tokens', createToken); 19 | 20 | const port = process.env.port || 3333; 21 | const server = app.listen(port, () => { 22 | console.log(`Listening at http://localhost:${port}/api`); 23 | }); 24 | 25 | server.on('error', console.error); 26 | -------------------------------------------------------------------------------- /apps/sandbox-api/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"] 6 | }, 7 | "exclude": ["**/*.spec.ts"], 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/sandbox-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest", "express"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/sandbox-api/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/sandbox-api/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": {} } 2 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fileServerFolder": ".", 3 | "fixturesFolder": "./src/fixtures", 4 | "integrationFolder": "./src/integration", 5 | "pluginsFile": "./src/plugins/index", 6 | "supportFile": "./src/support/index.ts", 7 | "video": true, 8 | "videosFolder": "../../dist/cypress/apps/sandbox-e2e/videos", 9 | "screenshotsFolder": "../../dist/cypress/apps/sandbox-e2e/screenshots", 10 | "chromeWebSecurity": false 11 | } 12 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io" 4 | } 5 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/integration/signin.spec.ts: -------------------------------------------------------------------------------- 1 | import { signIn } from '../support/signin.po'; 2 | 3 | describe('signin', () => { 4 | before(() => cy.visit('/')); 5 | 6 | it('should be landing page', () => { 7 | cy.url().should('include', '/signin'); 8 | }); 9 | 10 | describe('when signed in', () => { 11 | before(() => signIn()); 12 | 13 | it('should redirect to bikes', () => { 14 | cy.url().should('include', '/bikes'); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor'); 15 | 16 | module.exports = (on, config) => { 17 | // `on` is used to hook into various events Cypress emits 18 | // `config` is the resolved Cypress config 19 | 20 | // Preprocess Typescript 21 | on('file:preprocessor', preprocessTypescript(config)); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | declare namespace Cypress { 2 | interface Chainable { 3 | getByDataRole(dataRole: string): Chainable>; 4 | } 5 | } 6 | 7 | Cypress.Commands.add('getByDataRole', dataRole => { 8 | return cy.get(`[data-role="${dataRole}"]`); 9 | }); 10 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/src/support/signin.po.ts: -------------------------------------------------------------------------------- 1 | export function signIn() { 2 | cy.getByDataRole('login') 3 | .clear() 4 | .type('demo'); 5 | cy.getByDataRole('password') 6 | .clear() 7 | .type('demo'); 8 | cy.getByDataRole('signin-submit-button').click(); 9 | } 10 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "../../dist/out-tsc" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | "../../node_modules/@types/jest/index.d.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["cypress", "node"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /apps/sandbox-e2e/tslint.json: -------------------------------------------------------------------------------- 1 | { "extends": "../../tslint.json", "rules": [] } 2 | -------------------------------------------------------------------------------- /apps/sandbox/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /apps/sandbox/README.md: -------------------------------------------------------------------------------- 1 |

2 | convoyr logo 3 |

4 | 5 | # Demo 6 | 7 | This Angular app is used as an example. Find out more on [the main documentation](https://github.com/jscutlery/convoyr). 8 | 9 | Note that the [HttpModule](./src/app/http/http.module.ts) exposes all the stuff related to Convoyr. 10 | -------------------------------------------------------------------------------- /apps/sandbox/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'sandbox', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/apps/sandbox', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'] 11 | }; 12 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { IsAuthenticatedGuard } from './auth/is-authenticated.guard'; 5 | import { IsNotAuthenticatedGuard } from './auth/is-not-authenticated.guard'; 6 | import { BikeDetailComponent } from './bike-detail/bike-detail.component'; 7 | import { BikeSearchComponent } from './bike-search/bike-search.component'; 8 | import { RetryComponent } from './retry/retry.component'; 9 | import { SigninComponent } from './signin/signin.component'; 10 | 11 | const routes: Routes = [ 12 | { path: '', pathMatch: 'full', redirectTo: 'bikes' }, 13 | { 14 | path: '', 15 | canActivate: [IsNotAuthenticatedGuard], 16 | children: [{ path: 'signin', component: SigninComponent }], 17 | }, 18 | { 19 | path: '', 20 | canActivate: [IsAuthenticatedGuard], 21 | children: [ 22 | { path: 'bikes', component: BikeSearchComponent }, 23 | { path: 'bikes/:bikeId', component: BikeDetailComponent }, 24 | { path: 'retry', component: RetryComponent }, 25 | ], 26 | }, 27 | ]; 28 | 29 | @NgModule({ 30 | imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], 31 | exports: [RouterModule], 32 | }) 33 | export class AppRoutingModule {} 34 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | template: ` 6 | 7 | 8 | 9 | `, 10 | }) 11 | export class AppComponent {} 12 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | 6 | import { AppRoutingModule } from './app-routing.module'; 7 | import { AppComponent } from './app.component'; 8 | import { BikeDetailModule } from './bike-detail/bike-detail.component'; 9 | import { BikeSearchModule } from './bike-search/bike-search.component'; 10 | import { BikeCardModule } from './bike/bike-card.component'; 11 | import { NavModule } from './nav/nav.component'; 12 | import { RetryModule } from './retry/retry.component'; 13 | import { SigninModule } from './signin/signin.component'; 14 | import { HttpModule } from './http/http.module'; 15 | 16 | @NgModule({ 17 | declarations: [AppComponent], 18 | imports: [ 19 | NavModule, 20 | BikeCardModule, 21 | BikeDetailModule, 22 | SigninModule, 23 | RetryModule, 24 | BikeSearchModule, 25 | BrowserModule, 26 | AppRoutingModule, 27 | BrowserAnimationsModule, 28 | MatSnackBarModule, 29 | HttpModule, 30 | ], 31 | bootstrap: [AppComponent], 32 | }) 33 | export class AppModule {} 34 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class AuthService { 9 | private _token = new BehaviorSubject(undefined); 10 | 11 | get token$() { 12 | return this._token.asObservable(); 13 | } 14 | 15 | readonly isAuthenticated$ = this._token.pipe(map((token) => token != null)); 16 | 17 | setToken(token: string | undefined): void { 18 | this._token.next(token); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/auth/is-authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { first, map } from 'rxjs/operators'; 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class IsAuthenticatedGuard implements CanActivate { 10 | constructor(private auth: AuthService, private router: Router) {} 11 | 12 | canActivate() { 13 | return this.auth.isAuthenticated$.pipe( 14 | first(), 15 | map((isAuthenticated) => { 16 | return isAuthenticated ? true : this.router.createUrlTree(['/signin']); 17 | }) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/auth/is-not-authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { first, map } from 'rxjs/operators'; 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class IsNotAuthenticatedGuard implements CanActivate { 10 | constructor(private auth: AuthService, private router: Router) {} 11 | 12 | canActivate() { 13 | return this.auth.isAuthenticated$.pipe( 14 | first(), 15 | map((isAuthenticated) => { 16 | return isAuthenticated ? this.router.createUrlTree(['/']) : true; 17 | }) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/bike-detail/bike-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Component, NgModule } from '@angular/core'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { ActivatedRoute, RouterModule } from '@angular/router'; 6 | import { WithCacheMetadata } from '@convoyr/plugin-cache'; 7 | import { map, switchMap } from 'rxjs/operators'; 8 | 9 | import { Bike } from '../bike/bike'; 10 | import { BikeCardModule } from '../bike/bike-card.component'; 11 | import { environment } from './../../environments/environment'; 12 | 13 | @Component({ 14 | selector: 'app-bike-detail', 15 | template: ` 16 | BACK 17 |
18 | 19 |
    20 |
  • 21 | Price {{ bike.price }}€ 22 |
  • 23 |
  • 24 | Color {{ bike.color }} 25 |
  • 26 |
  • 27 | Type {{ bike.type }} 28 |
  • 29 |
30 |
31 | `, 32 | styles: [ 33 | ` 34 | a { 35 | margin: 8px; 36 | } 37 | 38 | .container { 39 | max-width: 480px; 40 | margin: 0 auto; 41 | } 42 | 43 | ul { 44 | padding: 16px; 45 | } 46 | 47 | li { 48 | display: flex; 49 | justify-content: space-between; 50 | padding-bottom: 6px; 51 | margin-top: 6px; 52 | border-bottom: 1px gray dotted; 53 | } 54 | `, 55 | ], 56 | }) 57 | export class BikeDetailComponent { 58 | bike$ = this.route.paramMap.pipe( 59 | map((paramMap) => paramMap.get('bikeId')), 60 | switchMap((bikeId) => 61 | this.httpClient.get>( 62 | `${environment.apiBaseUrl}/bikes/${encodeURIComponent(bikeId)}` 63 | ) 64 | ), 65 | map(({ data }) => data) 66 | ); 67 | 68 | constructor(private httpClient: HttpClient, private route: ActivatedRoute) {} 69 | } 70 | 71 | @NgModule({ 72 | declarations: [BikeDetailComponent], 73 | exports: [BikeDetailComponent], 74 | imports: [CommonModule, RouterModule, BikeCardModule, MatButtonModule], 75 | }) 76 | export class BikeDetailModule {} 77 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/bike-search/bike-search.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Component, NgModule, OnDestroy, OnInit } from '@angular/core'; 4 | import { FlexLayoutModule } from '@angular/flex-layout'; 5 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 6 | import { MatFormFieldModule } from '@angular/material/form-field'; 7 | import { MatInputModule } from '@angular/material/input'; 8 | import { RouterModule } from '@angular/router'; 9 | import { WithCacheMetadata } from '@convoyr/plugin-cache'; 10 | import { Subscription } from 'rxjs'; 11 | import { map, pluck, startWith, switchMap, shareReplay } from 'rxjs/operators'; 12 | 13 | import { environment } from '../../environments/environment'; 14 | import { Bike } from '../bike/bike'; 15 | import { BikeCardModule } from '../bike/bike-card.component'; 16 | 17 | @Component({ 18 | selector: 'app-bike-search', 19 | template: ` 20 |
21 | 22 | 28 | 29 |
30 |
36 | 42 |
43 | `, 44 | styles: [ 45 | ` 46 | .bike { 47 | cursor: pointer; 48 | min-width: 200px; 49 | max-width: 300px; 50 | margin: 10px; 51 | } 52 | 53 | .bikes { 54 | transition: filter 0.2s; 55 | } 56 | 57 | .is-from-cache { 58 | filter: blur(1px) grayscale(80%); 59 | } 60 | 61 | mat-form-field { 62 | margin-top: 8px; 63 | } 64 | `, 65 | ], 66 | }) 67 | export class BikeSearchComponent { 68 | searchControl = new FormControl(); 69 | bikesResponse$ = this.searchControl.valueChanges.pipe( 70 | startWith(''), 71 | switchMap((query) => 72 | this.http.get>( 73 | environment.apiBaseUrl + '/bikes', 74 | { 75 | params: { 76 | q: query, 77 | }, 78 | } 79 | ) 80 | ), 81 | shareReplay({ bufferSize: 1, refCount: true }) 82 | ); 83 | bikes$ = this.bikesResponse$.pipe(map(({ data }) => data.bikes)); 84 | isFromCache$ = this.bikesResponse$.pipe( 85 | map(({ cacheMetadata }) => cacheMetadata.isFromCache) 86 | ); 87 | 88 | constructor(private http: HttpClient) {} 89 | } 90 | 91 | @NgModule({ 92 | declarations: [BikeSearchComponent], 93 | exports: [BikeSearchComponent], 94 | imports: [ 95 | CommonModule, 96 | RouterModule, 97 | ReactiveFormsModule, 98 | MatFormFieldModule, 99 | MatInputModule, 100 | FlexLayoutModule, 101 | BikeCardModule, 102 | ], 103 | }) 104 | export class BikeSearchModule {} 105 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/bike/README.md: -------------------------------------------------------------------------------- 1 | Generated with : https://www.json-generator.com/ 2 | 3 | ``` 4 | [ 5 | '{{repeat(30, 30)}}', 6 | { 7 | id: '{{guid()}}', 8 | name: '{{state()}}', 9 | color: function (tags) { 10 | var fruits = ['red', 'blue', 'green', 'orange', 'pink']; 11 | return fruits[tags.integer(0, fruits.length - 1)]; 12 | }, 13 | price: '{{integer(100, 2000)}}', 14 | color: function (tags) { 15 | var fruits = ['red', 'blue', 'green', 'orange', 'pink']; 16 | return fruits[tags.integer(0, fruits.length - 1)]; 17 | }, 18 | type: function (tags) { 19 | var types = ['mtb', 'city', 'kids', 'electric']; 20 | return types[tags.integer(0, types.length - 1)]; 21 | } 22 | } 23 | ] 24 | ``` -------------------------------------------------------------------------------- /apps/sandbox/src/app/bike/bike-card.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | Input, 6 | NgModule, 7 | OnChanges, 8 | } from '@angular/core'; 9 | import { MatCardModule } from '@angular/material/card'; 10 | 11 | import { Bike } from './bike'; 12 | 13 | @Component({ 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | selector: 'app-bike-card', 16 | template: ` 17 | 18 | 19 | {{ bike.name }} 20 | {{ bike.type }} 21 | 22 | Photo of a bike 23 | 24 | 25 | `, 26 | styles: [ 27 | ` 28 | img[mat-card-image] { 29 | max-height: 200px; 30 | min-height: 200px; 31 | min-width: 300px; 32 | object-fit: cover; 33 | } 34 | `, 35 | ], 36 | }) 37 | export class BikeCardComponent implements OnChanges { 38 | @Input() bike: Bike; 39 | 40 | bikePictureUrl: string; 41 | 42 | ngOnChanges() { 43 | const color = encodeURIComponent(this.bike.color); 44 | this.bikePictureUrl = `https://source.unsplash.com/featured?${color}+bike`; 45 | } 46 | } 47 | 48 | @NgModule({ 49 | declarations: [BikeCardComponent], 50 | exports: [BikeCardComponent], 51 | imports: [CommonModule, MatCardModule], 52 | }) 53 | export class BikeCardModule {} 54 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/bike/bike.ts: -------------------------------------------------------------------------------- 1 | export type BikeType = 'mtb' | 'city' | 'bmx' | 'kids' | 'electric'; 2 | 3 | export interface Bike { 4 | id: string; 5 | name: string; 6 | color: string; 7 | pictureUrl: string; 8 | price: number; 9 | type: BikeType; 10 | } 11 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/http/create-logger-plugin.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrPlugin, and, matchMethod, matchOrigin } from '@convoyr/core'; 2 | 3 | export function createLoggerPlugin(): ConvoyrPlugin { 4 | return { 5 | shouldHandleRequest: and( 6 | matchMethod('GET'), 7 | matchOrigin('http://localhost:3333') 8 | ), 9 | handler: { 10 | handle({ request, next }) { 11 | console.log(`[${request.method}] ${request.url}`); 12 | return next.handle({ request }); 13 | }, 14 | }, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/http/http.module.ts: -------------------------------------------------------------------------------- 1 | import { and, matchMethod } from '@convoyr/core'; 2 | import { matchPath } from './../../../../../libs/core/src/lib/matchers/match-path/match-path'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { NgModule } from '@angular/core'; 5 | import { MatSnackBar } from '@angular/material/snack-bar'; 6 | import { Router } from '@angular/router'; 7 | import { ConvoyrModule } from '@convoyr/angular'; 8 | import { createAuthPlugin } from '@convoyr/plugin-auth'; 9 | import { createCachePlugin } from '@convoyr/plugin-cache'; 10 | import { createRetryPlugin } from '@convoyr/plugin-retry'; 11 | import { AuthService } from '../auth/auth.service'; 12 | import { createLoggerPlugin } from './create-logger-plugin'; 13 | import { rejectUnknownOriginsPlugin } from './reject-unknown-origins-plugin'; 14 | 15 | @NgModule({ 16 | imports: [ 17 | HttpClientModule, 18 | ConvoyrModule.forRoot({ 19 | deps: [AuthService, Router, MatSnackBar], 20 | config(auth: AuthService, router: Router, snackBar: MatSnackBar) { 21 | return { 22 | plugins: [ 23 | rejectUnknownOriginsPlugin, 24 | createLoggerPlugin(), 25 | createCachePlugin({ 26 | addCacheMetadata: true, 27 | }), 28 | createRetryPlugin(), 29 | createAuthPlugin({ 30 | token: auth.token$, 31 | onUnauthorized: () => { 32 | auth.setToken(undefined); 33 | router.navigate(['signin']); 34 | snackBar.open( 35 | `Unauthorized response handled. You've been redirect to the signin form.`, 36 | 'ok', 37 | { 38 | duration: 12000, 39 | } 40 | ); 41 | }, 42 | }), 43 | ], 44 | }; 45 | }, 46 | }), 47 | ], 48 | exports: [HttpClientModule, ConvoyrModule], 49 | }) 50 | export class HttpModule {} 51 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/http/reject-unknown-origins-plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, createResponse, ConvoyrResponse } from '@convoyr/core'; 2 | import { createPluginTester, PluginTester } from '@convoyr/core/testing'; 3 | import { ObserverSpy } from '@hirez_io/observer-spy'; 4 | import { rejectUnknownOriginsPlugin } from './reject-unknown-origins-plugin'; 5 | 6 | describe('rejectUnknownOriginsPlugin', () => { 7 | let pluginTester: PluginTester; 8 | let observerSpy: ObserverSpy; 9 | 10 | beforeEach(() => { 11 | observerSpy = new ObserverSpy({ expectErrors: true }); 12 | pluginTester = createPluginTester({ 13 | plugin: rejectUnknownOriginsPlugin, 14 | }); 15 | }); 16 | 17 | it('should reject unknown origins', () => { 18 | const httpHandlerMock = pluginTester.createHttpHandlerMock({ 19 | response: createResponse({ body: null }), 20 | }); 21 | 22 | const response$ = pluginTester.handleFake({ 23 | request: createRequest({ url: 'https://rejected-origin.com' }), 24 | httpHandlerMock, 25 | }); 26 | 27 | response$.subscribe(observerSpy); 28 | 29 | expect(httpHandlerMock).not.toHaveBeenCalled(); 30 | expect(observerSpy.receivedNext()).toBe(false); 31 | expect(observerSpy.receivedError()).toBe(true); 32 | expect(observerSpy.getError()).toBe( 33 | `🛑 Requesting invalid origin, url: https://rejected-origin.com` 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/http/reject-unknown-origins-plugin.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrPlugin, matchOrigin, not } from '@convoyr/core'; 2 | import { throwError } from 'rxjs'; 3 | 4 | export const rejectUnknownOriginsPlugin: ConvoyrPlugin = { 5 | shouldHandleRequest: not(matchOrigin('http://localhost:3333')), 6 | handler: { 7 | handle({ request }) { 8 | return throwError(`🛑 Requesting invalid origin, url: ${request.url}`); 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/nav/nav.component.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; 2 | import { CommonModule } from '@angular/common'; 3 | import { Component, NgModule } from '@angular/core'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatListModule } from '@angular/material/list'; 6 | import { MatSidenavModule } from '@angular/material/sidenav'; 7 | import { MatToolbarModule } from '@angular/material/toolbar'; 8 | import { RouterModule, Router } from '@angular/router'; 9 | import { Observable } from 'rxjs'; 10 | import { map, shareReplay } from 'rxjs/operators'; 11 | 12 | import { AuthService } from '../auth/auth.service'; 13 | import { MatSnackBar } from '@angular/material/snack-bar'; 14 | 15 | @Component({ 16 | selector: 'app-nav', 17 | template: ` 18 | 19 | 27 | Menu 28 | 29 | Bikes 30 | Retry 31 | 32 | 33 | 34 | 35 | 36 | 45 | 46 | Convoyr 47 | 48 | 49 | 50 | Welcome home 51 | 54 | 55 | 56 | 57 | 58 | 59 | `, 60 | styles: [ 61 | ` 62 | .sidenav-container { 63 | height: 100%; 64 | } 65 | 66 | .sidenav { 67 | width: 200px; 68 | } 69 | 70 | .sidenav .mat-toolbar { 71 | background: inherit; 72 | } 73 | 74 | .mat-toolbar.mat-primary { 75 | position: sticky; 76 | top: 0; 77 | z-index: 1; 78 | } 79 | 80 | .logo { 81 | width: 32px; 82 | margin-right: 10px; 83 | } 84 | 85 | .brand { 86 | display: flex; 87 | align-items: center; 88 | } 89 | 90 | .brand strong { 91 | margin-right: 6px; 92 | } 93 | 94 | .signed-in { 95 | font-size: 14px; 96 | display: flex; 97 | align-items: center; 98 | } 99 | 100 | .signed-in button { 101 | margin-left: 8px; 102 | } 103 | 104 | .authorized { 105 | margin-right: 6px; 106 | } 107 | 108 | mat-toolbar { 109 | display: flex; 110 | align-items: center; 111 | justify-content: space-between; 112 | } 113 | `, 114 | ], 115 | }) 116 | export class NavComponent { 117 | isHandset$: Observable = this.breakpointObserver 118 | .observe(Breakpoints.Handset) 119 | .pipe( 120 | map((result) => result.matches), 121 | shareReplay() 122 | ); 123 | 124 | isAuthenticated$ = this.auth.isAuthenticated$; 125 | 126 | constructor( 127 | private breakpointObserver: BreakpointObserver, 128 | private auth: AuthService, 129 | private snackbar: MatSnackBar, 130 | private router: Router 131 | ) {} 132 | 133 | markTokenAsExpired(): void { 134 | this.auth.setToken('EXPIRED'); 135 | const snackbar = this.snackbar.open( 136 | 'The next HTTP request will trigger an Unauthorized error response.', 137 | 'Navigate', 138 | { duration: 12000 } 139 | ); 140 | 141 | snackbar 142 | .onAction() 143 | .subscribe(() => 144 | this.router.navigate(['bikes', 'e802ccda-db66-4ac9-ae16-ae1eee9e0ee0']) 145 | ); 146 | } 147 | } 148 | 149 | @NgModule({ 150 | declarations: [NavComponent], 151 | exports: [NavComponent], 152 | imports: [ 153 | CommonModule, 154 | MatToolbarModule, 155 | MatButtonModule, 156 | MatSidenavModule, 157 | MatListModule, 158 | RouterModule, 159 | ], 160 | }) 161 | export class NavModule {} 162 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/retry/retry.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Component, NgModule, OnDestroy } from '@angular/core'; 4 | import { FlexLayoutModule } from '@angular/flex-layout'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 7 | import { Subscription } from 'rxjs'; 8 | import { environment } from '../../environments/environment'; 9 | 10 | @Component({ 11 | selector: 'app-retry', 12 | template: ` 13 |
14 |
15 | Open developer tools network panel to see HTTP retries. 16 |
17 |
18 |
19 | {{ loading ? 'Fetching...' : 'Error:' }} 20 |
21 | 22 |
23 | 24 |
{{ error | json }}
25 |
26 |
27 |
28 | 31 |
32 | `, 33 | styles: [ 34 | ` 35 | .container { 36 | height: 100%; 37 | } 38 | 39 | button { 40 | margin-right: 8px; 41 | } 42 | 43 | .error-message, 44 | .loader { 45 | height: 100px; 46 | padding: 10px; 47 | margin: 10px; 48 | } 49 | 50 | .tip { 51 | margin-bottom: 40px; 52 | font-size: 13px; 53 | font-style: italic; 54 | } 55 | `, 56 | ], 57 | }) 58 | export class RetryComponent implements OnDestroy { 59 | subscription: Subscription; 60 | 61 | loading = false; 62 | 63 | error: any = {}; 64 | 65 | constructor(private http: HttpClient) {} 66 | 67 | ngOnDestroy(): void { 68 | this.error = {}; 69 | this.loading = false; 70 | } 71 | 72 | getServerError(): void { 73 | this.error = {}; 74 | this.loading = true; 75 | this.subscription?.unsubscribe(); 76 | this.subscription = this.http 77 | .get(environment.apiBaseUrl + '/server-error') 78 | .subscribe({ 79 | error: (response) => { 80 | this.error = response.error; 81 | this.loading = false; 82 | }, 83 | }); 84 | } 85 | } 86 | 87 | @NgModule({ 88 | declarations: [RetryComponent], 89 | exports: [RetryComponent], 90 | imports: [ 91 | CommonModule, 92 | MatButtonModule, 93 | FlexLayoutModule, 94 | MatProgressSpinnerModule, 95 | ], 96 | }) 97 | export class RetryModule {} 98 | -------------------------------------------------------------------------------- /apps/sandbox/src/app/signin/signin.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Component, NgModule } from '@angular/core'; 4 | import { 5 | FormControl, 6 | FormGroup, 7 | ReactiveFormsModule, 8 | Validators, 9 | } from '@angular/forms'; 10 | import { MatButtonModule } from '@angular/material/button'; 11 | import { MatFormFieldModule } from '@angular/material/form-field'; 12 | import { MatInputModule } from '@angular/material/input'; 13 | import { Router } from '@angular/router'; 14 | import { environment } from '../../environments/environment'; 15 | import { AuthService } from '../auth/auth.service'; 16 | 17 | @Component({ 18 | selector: 'app-sign-in', 19 | template: ` 20 |
21 | 22 | Login 23 | 24 | 25 | 26 | Password 27 | 33 | 34 | 43 |
44 |
Valid login: demo
45 |
Valid password: demo
46 |
47 |
48 | Error: 49 |
{{ error | json }}
50 |
51 |
52 | `, 53 | styles: [ 54 | ` 55 | .form { 56 | width: 300px; 57 | margin: 100px auto; 58 | } 59 | 60 | .form .field { 61 | width: 100%; 62 | display: block; 63 | } 64 | 65 | button { 66 | margin-top: 20px; 67 | } 68 | 69 | .info, 70 | .error { 71 | margin-top: 45px; 72 | } 73 | `, 74 | ], 75 | }) 76 | export class SigninComponent { 77 | error: any; 78 | form = new FormGroup({ 79 | login: new FormControl('demo', [Validators.required]), 80 | password: new FormControl('demo', [Validators.required]), 81 | }); 82 | 83 | constructor( 84 | private auth: AuthService, 85 | private http: HttpClient, 86 | private router: Router 87 | ) {} 88 | 89 | signIn(): void { 90 | if (!this.form.valid) { 91 | return; 92 | } 93 | 94 | this.error = undefined; 95 | this.http 96 | .post<{ token: string }>( 97 | `${environment.apiBaseUrl}/tokens`, 98 | this.form.value 99 | ) 100 | .subscribe({ 101 | next: (response) => { 102 | this.auth.setToken(response.token); 103 | this.router.navigate(['/']); 104 | }, 105 | error: (response) => (this.error = response.error), 106 | }); 107 | } 108 | } 109 | 110 | @NgModule({ 111 | declarations: [SigninComponent], 112 | exports: [SigninComponent], 113 | imports: [ 114 | CommonModule, 115 | ReactiveFormsModule, 116 | MatFormFieldModule, 117 | MatInputModule, 118 | MatButtonModule, 119 | ], 120 | }) 121 | export class SigninModule {} 122 | -------------------------------------------------------------------------------- /apps/sandbox/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/apps/sandbox/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/sandbox/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/sandbox/src/assets/verified_user.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/sandbox/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiBaseUrl: 'http://localhost:3333/api' 4 | }; 5 | -------------------------------------------------------------------------------- /apps/sandbox/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | apiBaseUrl: 'http://localhost:3333/api' 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /apps/sandbox/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/apps/sandbox/src/favicon.ico -------------------------------------------------------------------------------- /apps/sandbox/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sandbox 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/sandbox/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /apps/sandbox/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | /*************************************************************************************************** 61 | * APPLICATION IMPORTS 62 | */ 63 | -------------------------------------------------------------------------------- /apps/sandbox/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | html, 3 | body { 4 | height: 100%; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | font-family: Roboto, 'Helvetica Neue', sans-serif; 10 | } 11 | -------------------------------------------------------------------------------- /apps/sandbox/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /apps/sandbox/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["**/*.d.ts"], 9 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/sandbox/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"], 5 | "allowSyntheticDefaultImports": true 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/sandbox/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/sandbox/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "app", "camelCase"], 5 | "component-selector": [true, "element", "app", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | status: 5 | project: 6 | angular: 7 | target: 90% 8 | flags: angular 9 | core: 10 | flags: core 11 | target: 90% 12 | plugin-auth: 13 | target: 90% 14 | flags: plugin-auth 15 | plugin-cache: 16 | target: 90% 17 | flags: plugin-cache 18 | plugin-retry: 19 | target: 90% 20 | flags: plugin-retry 21 | patch: 22 | default: 23 | enabled: no 24 | if_not_found: success 25 | changes: 26 | default: 27 | enabled: no 28 | if_not_found: success 29 | flags: 30 | angular: 31 | paths: 32 | - libs/angular/src 33 | core: 34 | paths: 35 | - libs/core/src 36 | plugin-auth: 37 | paths: 38 | - libs/plugin-auth/src 39 | plugin-cache: 40 | paths: 41 | - libs/plugin-cache/src 42 | plugin-retry: 43 | paths: 44 | - libs/plugin-retry/src 45 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | ['chore', 'docs', 'feat', 'fix', 'test', 'wip', 'release'], 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'], 3 | transform: { 4 | '^.+\\.(ts|js|html)$': 'ts-jest' 5 | }, 6 | resolver: '@nrwl/jest/plugins/resolver', 7 | moduleFileExtensions: ['ts', 'js', 'html'], 8 | coverageReporters: ['html'], 9 | passWithNoTests: true 10 | }; 11 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.0.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "packages": [ 6 | "libs/*" 7 | ], 8 | "command": { 9 | "version": { 10 | "conventionalCommits": true, 11 | "message": "release: 🚀 publish %s", 12 | "allowBranch": "master" 13 | }, 14 | "publish": { 15 | "conventionalCommits": true, 16 | "allowBranch": "master", 17 | "contents": "dist" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /libs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/libs/.gitkeep -------------------------------------------------------------------------------- /libs/angular/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 | # [4.0.0](https://github.com/jscutlery/convoyr/compare/v3.2.0...v4.0.0) (2020-05-30) 7 | 8 | **Note:** Version bump only for package @convoyr/angular 9 | 10 | 11 | 12 | 13 | 14 | # [3.2.0](https://github.com/jscutlery/convoyr/compare/v3.1.0...v3.2.0) (2020-05-23) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * 📦 update angular monorepo to v9.1.9 ([ebbf2c3](https://github.com/jscutlery/convoyr/commit/ebbf2c3a0813b6070b263ef6a0d2b697112876ad)) 20 | * **angular:** 🐞 handle and convert `HttpErrorResponse` ([84dfb74](https://github.com/jscutlery/convoyr/commit/84dfb7485009b9bedfcab71142895fbc3e7abc62)) 21 | 22 | 23 | 24 | 25 | 26 | # [3.1.0](https://github.com/jscutlery/convoyr/compare/v3.0.0...v3.1.0) (2020-05-20) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * 📦 update angular monorepo to v9.1.4 ([1d36585](https://github.com/jscutlery/convoyr/commit/1d365857e34d46231ddb2ecbf11b9385608f2309)) 32 | * 📦 update angular monorepo to v9.1.5 ([c31b271](https://github.com/jscutlery/convoyr/commit/c31b2718efd2101982fe1d69db753e221f61ebe0)) 33 | * 📦 update angular monorepo to v9.1.6 ([abe038d](https://github.com/jscutlery/convoyr/commit/abe038d6363b600e3f1116b605e739a22b0b85f8)) 34 | * 📦 update angular monorepo to v9.1.7 ([1cd5b38](https://github.com/jscutlery/convoyr/commit/1cd5b38c66c1f70e62fbc5e0829839b5c5273810)) 35 | 36 | 37 | 38 | 39 | 40 | # [3.0.0](https://github.com/jscutlery/convoyr/compare/v2.2.0...v3.0.0) (2020-04-24) 41 | 42 | 43 | ### Features 44 | 45 | * ✅ `NextFn` to `NextHandler` object ([530cb97](https://github.com/jscutlery/convoyr/commit/530cb97dab4404bfc9e2ad5b035a855a73b95a39)) 46 | 47 | 48 | ### BREAKING CHANGES 49 | 50 | * The `NextFn` type used for calling the next plugin and 51 | the final HTTP handler is removed in favor of an object following the `NextHandler` interface. 52 | 53 | 54 | 55 | 56 | 57 | # [2.2.0](https://github.com/jscutlery/convoyr/compare/v2.1.1...v2.2.0) (2020-04-23) 58 | 59 | ### Bug Fixes 60 | 61 | - 📦 update angular monorepo to v9.1.3 ([fb3a984](https://github.com/jscutlery/convoyr/commit/fb3a984655ebbb0df68b43d32efcd57bc952a615)) 62 | 63 | ## [2.1.1](https://github.com/jscutlery/convoyr/compare/v2.1.0...v2.1.1) (2020-04-16) 64 | 65 | ### Bug Fixes 66 | 67 | - 📦 update angular monorepo to v9.1.2 ([a5a5b8f](https://github.com/jscutlery/convoyr/commit/a5a5b8f3688f98122d3e53167d3a975f076d80f8)) 68 | 69 | # [2.1.0](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.1.0) (2020-04-11) 70 | 71 | **Note:** Version bump only for package @convoyr/angular 72 | 73 | ## [2.0.2](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.0.2) (2020-04-08) 74 | 75 | **Note:** Version bump only for package @convoyr/angular 76 | 77 | ## [2.0.1](https://github.com/jscutlery/convoyr/compare/v2.0.0...v2.0.1) (2020-04-07) 78 | 79 | **Note:** Version bump only for package @convoyr/angular 80 | 81 | # [2.0.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v2.0.0) (2020-04-01) 82 | 83 | **Note:** Version bump only for package @convoyr/angular 84 | 85 | # [1.2.0](https://github.com/jscutlery/convoyr/compare/v1.1.0...v1.2.0) (2020-03-31) 86 | 87 | **Note:** Version bump only for package @convoyr/angular 88 | 89 | # [1.1.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v1.1.0) (2020-01-14) 90 | 91 | **Note:** Version bump only for package @convoyr/angular 92 | 93 | # [1.0.0](https://github.com/jscutlery/convoyr/compare/v0.1.1...v1.0.0) (2020-01-06) 94 | 95 | - feat!: :white_check_mark: use an object as plugin handler ([47a5e9f](https://github.com/jscutlery/convoyr/commit/47a5e9f87d9c4256578a005d77516cb2d7034327)) 96 | 97 | ### BREAKING CHANGES 98 | 99 | - the plugin handler become an object 100 | 101 | ## [0.1.1](https://github.com/jscutlery/convoyr/compare/v0.1.0...v0.1.1) (2019-11-20) 102 | 103 | **Note:** Version bump only for package @convoyr/angular 104 | 105 | # 0.1.0 (2019-11-19) 106 | 107 | ### Bug Fixes 108 | 109 | - :beetle: package dependency build ([b510752](https://github.com/jscutlery/convoyr/commit/b51075254dc2e337e3e8b5ef293156abf4bf54ff)) 110 | - :beetle: use path mapping to internally expose `_createSpyPlugin` ([9f1e845](https://github.com/jscutlery/convoyr/commit/9f1e8459738c2d0571cde0e95d4f9be19d64a440)) 111 | - 🐞 fix AOT issue "Expression form not supported" when using inline type with `@Inject` ([5a93caf](https://github.com/jscutlery/convoyr/commit/5a93caf536df1df9e01e3049cc2d8aed2f088eba)) 112 | - 🐞 fix AOT issue due to logic in `forRoot` ([9f2a093](https://github.com/jscutlery/convoyr/commit/9f2a093dda9b5f42b47fefcdefa735f1582380be)) 113 | 114 | ### Features 115 | 116 | - :white_check_mark: split packages ([77b22c0](https://github.com/jscutlery/convoyr/commit/77b22c01f5de59f02aa28e8bd3fd46e2c49d3bff)) 117 | -------------------------------------------------------------------------------- /libs/angular/README.md: -------------------------------------------------------------------------------- 1 | # @convoyr/angular 2 | 3 | This is part of the Convoyr library. 4 | 5 | [Find out more on our GitHub repo](https://github.com/jscutlery/convoyr) 6 | -------------------------------------------------------------------------------- /libs/angular/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'angular', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/angular', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'] 11 | }; 12 | -------------------------------------------------------------------------------- /libs/angular/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "dist", 4 | "lib": { 5 | "entryFile": "src/index.ts", 6 | "umdModuleIds": { 7 | "@convoyr/core": "@convoyr/core" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /libs/angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convoyr/angular", 3 | "version": "4.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "repository": "git@github.com:jscutlery/convoyr.git", 7 | "scripts": { 8 | "prepublishOnly": "ng build angular --prod" 9 | }, 10 | "dependencies": { 11 | "tslib": "^2.0.0" 12 | }, 13 | "peerDependencies": { 14 | "@angular/common": ">= 9.1.11", 15 | "@angular/core": ">= 10.2.5", 16 | "@convoyr/core": ">= 3.0.0", 17 | "rxjs": "^6.5.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/angular/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as publicApi from './index'; 2 | 3 | describe('Public API', () => { 4 | it('should expose ConvoyrModule', () => { 5 | expect(publicApi.ConvoyrModule).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /libs/angular/src/index.ts: -------------------------------------------------------------------------------- 1 | export { ConvoyrModule } from './lib/convoyr.module'; 2 | -------------------------------------------------------------------------------- /libs/angular/src/lib/convoyr.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpEvent, 3 | HttpHandler, 4 | HttpInterceptor, 5 | HttpRequest, 6 | HttpResponse, 7 | HttpErrorResponse, 8 | } from '@angular/common/http'; 9 | import { Inject, Injectable, InjectionToken } from '@angular/core'; 10 | import { Convoyr, ConvoyrConfig, ConvoyrResponse } from '@convoyr/core'; 11 | import { Observable, throwError } from 'rxjs'; 12 | import { filter, map, catchError } from 'rxjs/operators'; 13 | 14 | import { 15 | fromNgRequest, 16 | fromNgResponse, 17 | toNgRequest, 18 | toNgResponse, 19 | fromNgErrorResponse, 20 | toNgErrorResponse, 21 | ErrorBody, 22 | } from './http-converter'; 23 | 24 | /** 25 | * @internal 26 | */ 27 | export const _CONVOYR_CONFIG = new InjectionToken( 28 | 'Convoyr Config' 29 | ); 30 | 31 | @Injectable() 32 | export class ConvoyrInterceptor implements HttpInterceptor { 33 | private _convoyr = new Convoyr(this._convoyConfig); 34 | 35 | constructor( 36 | @Inject(_CONVOYR_CONFIG) 37 | private _convoyConfig: ConvoyrConfig 38 | ) {} 39 | 40 | intercept( 41 | ngRequest: HttpRequest, 42 | next: HttpHandler 43 | ): Observable> { 44 | return this._convoyr 45 | .handle({ 46 | request: fromNgRequest(ngRequest), 47 | httpHandler: { 48 | handle: ({ request }) => 49 | next.handle(toNgRequest(request)).pipe( 50 | filter( 51 | (httpEvent) => 52 | httpEvent instanceof HttpResponse || 53 | httpEvent instanceof HttpErrorResponse 54 | ), 55 | map(fromNgResponse), 56 | catchError((error: HttpErrorResponse) => 57 | throwError(fromNgErrorResponse(error)) 58 | ) 59 | ), 60 | }, 61 | }) 62 | .pipe( 63 | map(toNgResponse), 64 | catchError((error: ConvoyrResponse) => 65 | throwError(toNgErrorResponse(error)) 66 | ) 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /libs/angular/src/lib/convoyr.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpResponse } from '@angular/common/http'; 2 | import { 3 | HttpClientTestingModule, 4 | HttpTestingController, 5 | } from '@angular/common/http/testing'; 6 | import { TestBed } from '@angular/core/testing'; 7 | import { createSpyPlugin, SpyPlugin } from '@convoyr/core/testing'; 8 | import { ObserverSpy } from '@hirez_io/observer-spy'; 9 | import { _CONVOYR_CONFIG } from './convoyr.interceptor'; 10 | import { ConvoyrModule } from './convoyr.module'; 11 | 12 | describe('ConvoyrModule', () => { 13 | let spyPlugin: SpyPlugin; 14 | let observerSpy: ObserverSpy>; 15 | 16 | describe('with config', () => { 17 | beforeEach(() => { 18 | spyPlugin = createSpyPlugin(); 19 | observerSpy = new ObserverSpy(); 20 | 21 | TestBed.configureTestingModule({ 22 | imports: [ 23 | HttpClientTestingModule, 24 | ConvoyrModule.forRoot({ 25 | plugins: [spyPlugin], 26 | }), 27 | ], 28 | }); 29 | }); 30 | 31 | let httpClient: HttpClient; 32 | beforeEach(() => (httpClient = TestBed.inject(HttpClient))); 33 | 34 | let httpController: HttpTestingController; 35 | beforeEach(() => (httpController = TestBed.inject(HttpTestingController))); 36 | 37 | afterEach(() => httpController.verify()); 38 | 39 | it('should handle http request', () => { 40 | httpClient 41 | .get('https://jscutlery.github.io/items/ITEM_ID') 42 | .subscribe(observerSpy); 43 | 44 | httpController 45 | .expectOne('https://jscutlery.github.io/items/ITEM_ID') 46 | .flush({ 47 | id: 'ITEM_ID', 48 | title: 'ITEM_TITLE', 49 | }); 50 | 51 | expect(observerSpy.receivedNext()).toBe(true); 52 | expect(observerSpy.receivedComplete()).toBe(true); 53 | expect(observerSpy.getLastValue()).toEqual({ 54 | id: 'ITEM_ID', 55 | title: 'ITEM_TITLE', 56 | }); 57 | 58 | expect(spyPlugin.handler.handle).toHaveBeenCalledTimes(1); 59 | expect(spyPlugin.handler.handle.mock.calls[0][0].request).toEqual({ 60 | url: 'https://jscutlery.github.io/items/ITEM_ID', 61 | method: 'GET', 62 | body: null, 63 | headers: {}, 64 | params: {}, 65 | responseType: 'json', 66 | }); 67 | expect(typeof spyPlugin.handler.handle.mock.calls[0][0].next).toEqual( 68 | 'object' 69 | ); 70 | expect( 71 | typeof spyPlugin.handler.handle.mock.calls[0][0].next.handle 72 | ).toEqual('function'); 73 | }); 74 | }); 75 | 76 | describe('with dynamic config', () => { 77 | beforeEach(() => { 78 | spyPlugin = createSpyPlugin(); 79 | observerSpy = new ObserverSpy(); 80 | 81 | TestBed.configureTestingModule({ 82 | imports: [ 83 | HttpClientTestingModule, 84 | ConvoyrModule.forRoot({ 85 | config: () => ({ 86 | plugins: [spyPlugin], 87 | }), 88 | }), 89 | ], 90 | }); 91 | }); 92 | 93 | let httpClient: HttpClient; 94 | beforeEach(() => (httpClient = TestBed.inject(HttpClient))); 95 | 96 | let httpController: HttpTestingController; 97 | beforeEach(() => (httpController = TestBed.inject(HttpTestingController))); 98 | 99 | afterEach(() => httpController.verify()); 100 | 101 | it('should handle http request', () => { 102 | httpClient 103 | .get('https://jscutlery.github.io/items/ITEM_ID') 104 | .subscribe(observerSpy); 105 | 106 | httpController 107 | .expectOne('https://jscutlery.github.io/items/ITEM_ID') 108 | .flush({}); 109 | 110 | expect(observerSpy.receivedNext()).toBe(true); 111 | expect(observerSpy.receivedComplete()).toBe(true); 112 | expect(spyPlugin.handler.handle).toHaveBeenCalledTimes(1); 113 | }); 114 | }); 115 | 116 | describe('with dynamic config and dependency injection', () => { 117 | class Service {} 118 | 119 | let configFn: jest.Mock; 120 | let service: Service; 121 | 122 | beforeEach(() => { 123 | configFn = jest.fn(); 124 | service = new Service(); 125 | 126 | TestBed.configureTestingModule({ 127 | providers: [ 128 | { 129 | provide: Service, 130 | useValue: service, 131 | }, 132 | ], 133 | imports: [ 134 | HttpClientTestingModule, 135 | ConvoyrModule.forRoot({ 136 | deps: [Service], 137 | config: configFn, 138 | }), 139 | ], 140 | }); 141 | }); 142 | 143 | it('should handle http request', () => { 144 | const config = {}; 145 | configFn.mockReturnValue(config); 146 | 147 | /* Injecting config triggers the config factory. */ 148 | expect(TestBed.inject(_CONVOYR_CONFIG)).toEqual(config); 149 | 150 | expect(configFn).toHaveBeenCalledTimes(1); 151 | expect(configFn).toHaveBeenCalledWith(service); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /libs/angular/src/lib/convoyr.module.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 2 | import { ModuleWithProviders, NgModule } from '@angular/core'; 3 | import { ConvoyrConfig } from '@convoyr/core'; 4 | 5 | import { _CONVOYR_CONFIG, ConvoyrInterceptor } from './convoyr.interceptor'; 6 | 7 | export type ConvoyrModuleArgs = 8 | | ConvoyrConfig 9 | | { 10 | deps?: unknown[]; 11 | config: (...args: unknown[]) => ConvoyrConfig; 12 | }; 13 | 14 | @NgModule({}) 15 | export class ConvoyrModule { 16 | static forRoot(args: ConvoyrModuleArgs): ModuleWithProviders { 17 | return { 18 | ngModule: ConvoyrModule, 19 | providers: [ 20 | { 21 | provide: _CONVOYR_CONFIG, 22 | ...('config' in args 23 | ? { 24 | deps: args.deps, 25 | useFactory: args.config, 26 | } 27 | : { 28 | useValue: args, 29 | }), 30 | }, 31 | { 32 | provide: HTTP_INTERCEPTORS, 33 | multi: true, 34 | useClass: ConvoyrInterceptor, 35 | }, 36 | ], 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /libs/angular/src/lib/http-converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http'; 2 | import { ConvoyrRequest } from '@convoyr/core'; 3 | 4 | import { fromNgRequest, toNgRequest } from './http-converter'; 5 | 6 | describe('fromNgRequest', () => { 7 | it('should convert HttpRequest with body to ConvoyrRequest object', () => { 8 | const ngRequest = new HttpRequest( 9 | 'POST', 10 | 'https://angular.io', 11 | { data: 'hello world' }, 12 | { headers: new HttpHeaders({ Authorization: 'token' }) } 13 | ); 14 | expect(fromNgRequest(ngRequest)).toEqual({ 15 | url: 'https://angular.io', 16 | method: 'POST', 17 | body: { data: 'hello world' }, 18 | headers: { Authorization: 'token' }, 19 | responseType: 'json', 20 | params: {}, 21 | }); 22 | }); 23 | 24 | it('should convert HttpRequest without body to ConvoyrRequest object', () => { 25 | const ngRequest = new HttpRequest('GET', 'https://wikipedia.com', { 26 | params: new HttpParams().set('id', '1'), 27 | }); 28 | expect(fromNgRequest(ngRequest)).toEqual({ 29 | url: 'https://wikipedia.com', 30 | method: 'GET', 31 | body: null, 32 | headers: {}, 33 | params: { id: '1' }, 34 | responseType: 'json', 35 | }); 36 | }); 37 | }); 38 | 39 | describe('toNgRequest', () => { 40 | it('should convert ConvoyrRequest with body to HttpRequest', () => { 41 | const request: ConvoyrRequest = { 42 | url: 'https://presidents.com', 43 | method: 'PUT', 44 | body: { data: { name: 'Jacques Chirac' } }, 45 | headers: { Authorization: 'Bearer token' }, 46 | responseType: 'json', 47 | params: { id: '1' }, 48 | }; 49 | 50 | const ngRequest = toNgRequest(request); 51 | expect(ngRequest).toEqual( 52 | expect.objectContaining({ 53 | method: 'PUT', 54 | url: 'https://presidents.com', 55 | body: { data: { name: 'Jacques Chirac' } }, 56 | }) 57 | ); 58 | expect(ngRequest.headers.get('Authorization')).toEqual('Bearer token'); 59 | }); 60 | 61 | it('should convert ConvoyrRequest without body to HttpRequest', () => { 62 | const request: ConvoyrRequest = { 63 | url: 'https://test.com', 64 | method: 'GET', 65 | body: null, 66 | headers: {}, 67 | responseType: 'json', 68 | params: {}, 69 | }; 70 | 71 | const ngRequest = toNgRequest(request); 72 | expect(ngRequest).toEqual( 73 | expect.objectContaining({ 74 | method: 'GET', 75 | url: 'https://test.com', 76 | }) 77 | ); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /libs/angular/src/lib/http-converter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpHeaders, 3 | HttpParams, 4 | HttpRequest, 5 | HttpResponse, 6 | HttpErrorResponse, 7 | } from '@angular/common/http'; 8 | import { 9 | createRequest, 10 | createResponse, 11 | ConvoyrRequest, 12 | ConvoyrResponse, 13 | HttpMethod, 14 | } from '@convoyr/core'; 15 | 16 | export function fromNgClass( 17 | ngClass: HttpHeaders | HttpParams 18 | ): { [key: string]: string } { 19 | return ngClass 20 | .keys() 21 | .reduce((_obj, key) => ({ [key]: ngClass.get(key) }), {}); 22 | } 23 | 24 | export function fromNgRequest( 25 | request: HttpRequest 26 | ): ConvoyrRequest { 27 | return createRequest({ 28 | url: request.url, 29 | method: request.method as HttpMethod, 30 | body: request.body, 31 | headers: fromNgClass(request.headers), 32 | params: fromNgClass(request.params), 33 | responseType: request.responseType, 34 | }); 35 | } 36 | 37 | export function toNgRequest( 38 | request: ConvoyrRequest 39 | ): HttpRequest { 40 | const init = { 41 | headers: new HttpHeaders(request.headers), 42 | params: new HttpParams({ fromObject: request.params }), 43 | responseType: request.responseType, 44 | }; 45 | 46 | if (['POST', 'PUT', 'PATCH'].includes(request.method)) { 47 | return new HttpRequest(request.method, request.url, request.body, init); 48 | } 49 | 50 | return new HttpRequest(request.method, request.url, init); 51 | } 52 | 53 | export function fromNgResponse(ngResponse: HttpResponse) { 54 | return createResponse({ 55 | body: ngResponse.body, 56 | headers: fromNgClass(ngResponse.headers), 57 | status: ngResponse.status, 58 | statusText: ngResponse.statusText, 59 | }); 60 | } 61 | 62 | export function fromNgErrorResponse( 63 | ngResponse: HttpErrorResponse 64 | ): ConvoyrResponse { 65 | return createResponse({ 66 | body: { error: ngResponse.error ?? null }, 67 | headers: fromNgClass(ngResponse.headers), 68 | status: ngResponse.status, 69 | statusText: ngResponse.statusText, 70 | }); 71 | } 72 | 73 | export function toNgResponse( 74 | response: ConvoyrResponse 75 | ): HttpResponse { 76 | return new HttpResponse({ 77 | body: response.body, 78 | headers: new HttpHeaders(response.headers), 79 | status: response.status, 80 | statusText: response.statusText, 81 | }); 82 | } 83 | 84 | export function toNgErrorResponse( 85 | response: ConvoyrResponse 86 | ): HttpErrorResponse { 87 | return new HttpErrorResponse({ 88 | error: response.body.error, 89 | headers: new HttpHeaders(response.headers), 90 | status: response.status, 91 | statusText: response.statusText, 92 | }); 93 | } 94 | 95 | export interface ErrorBody { 96 | error: unknown; 97 | } 98 | -------------------------------------------------------------------------------- /libs/angular/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/angular/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "declaration": true, 8 | "inlineSources": true, 9 | "types": [], 10 | "lib": ["dom", "es2018"], 11 | "paths": { 12 | "@convoyr/core": ["libs/core/dist"] 13 | } 14 | }, 15 | "angularCompilerOptions": { 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /libs/angular/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } -------------------------------------------------------------------------------- /libs/angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/angular/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "convoyr", "camelCase"], 5 | "component-selector": [true, "element", "convoyr", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/README.md: -------------------------------------------------------------------------------- 1 | # @convoyr/core 2 | 3 | This is part of the Convoyr library. 4 | 5 | [Find out more on our GitHub repo](https://github.com/jscutlery/convoyr) 6 | -------------------------------------------------------------------------------- /libs/core/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'core', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/core', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'] 11 | }; 12 | -------------------------------------------------------------------------------- /libs/core/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "dist", 4 | "lib": { 5 | "entryFile": "src/index.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convoyr/core", 3 | "version": "4.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "repository": "git@github.com:jscutlery/convoyr.git", 7 | "scripts": { 8 | "prepublishOnly": "ng build core --prod" 9 | }, 10 | "dependencies": { 11 | "tslib": "^2.0.0" 12 | }, 13 | "peerDependencies": { 14 | "rxjs": "^6.5.4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /libs/core/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as publicApi from './index'; 2 | 3 | describe('Public API', () => { 4 | it('should expose Convoyr', () => { 5 | expect(publicApi.Convoyr).toBeDefined(); 6 | expect(publicApi.matchOrigin).toBeDefined(); 7 | expect(publicApi.matchMethod).toBeDefined(); 8 | expect(publicApi.matchResponseType).toBeDefined(); 9 | expect(publicApi.matchPath).toBeDefined(); 10 | expect(publicApi.or).toBeDefined(); 11 | expect(publicApi.and).toBeDefined(); 12 | expect(publicApi.not).toBeDefined(); 13 | expect(publicApi.createRequest).toBeDefined(); 14 | expect(publicApi.createResponse).toBeDefined(); 15 | expect(publicApi.fromSyncOrAsync).toBeDefined(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /libs/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { ConvoyrPlugin, RequestCondition } from './lib/plugin'; 2 | export { PluginHandlerArgs, PluginHandler } from './lib/handler'; 3 | export { 4 | matchOrigin, 5 | matchMethod, 6 | matchResponseType, 7 | matchPath, 8 | or, 9 | and, 10 | not, 11 | } from './lib/matchers'; 12 | export { Convoyr, ConvoyrConfig } from './lib/convoyr'; 13 | export { NextHandler } from './lib/request-handler'; 14 | export { ConvoyrRequest, createRequest, HttpMethod } from './lib/request'; 15 | export { ConvoyrResponse, createResponse, ResponseArgs } from './lib/response'; 16 | export { fromSyncOrAsync } from './lib/utils/from-sync-or-async'; 17 | -------------------------------------------------------------------------------- /libs/core/src/lib/convoyr.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of, throwError } from 'rxjs'; 2 | import { mergeMap, tap } from 'rxjs/operators'; 3 | import { ConvoyrPlugin } from './plugin'; 4 | import { ConvoyrRequest } from './request'; 5 | import { NextHandler } from './request-handler'; 6 | import { ConvoyrResponse } from './response'; 7 | import { throwIfInvalidPluginCondition } from './throw-invalid-plugin-condition'; 8 | import { fromSyncOrAsync } from './utils/from-sync-or-async'; 9 | import { isFunction } from './utils/is-function'; 10 | 11 | export function invalidHandleRequestConditionError() { 12 | return new Error('"shouldHandleRequest" should be a function.'); 13 | } 14 | 15 | export interface ConvoyrConfig { 16 | plugins: ConvoyrPlugin[]; 17 | } 18 | 19 | export class Convoyr { 20 | private _plugins: ConvoyrPlugin[]; 21 | 22 | constructor({ plugins }: ConvoyrConfig) { 23 | this._plugins = plugins; 24 | } 25 | 26 | handle({ 27 | request, 28 | httpHandler, 29 | }: { 30 | request: ConvoyrRequest; 31 | httpHandler: NextHandler; 32 | }): Observable { 33 | return this._handle({ 34 | request, 35 | plugins: this._plugins, 36 | httpHandler, 37 | }); 38 | } 39 | 40 | private _handle({ 41 | request, 42 | plugins, 43 | httpHandler, 44 | }: { 45 | request: ConvoyrRequest; 46 | plugins: ConvoyrPlugin[]; 47 | httpHandler: NextHandler; 48 | }): Observable { 49 | if (plugins.length === 0) { 50 | return httpHandler.handle({ request }); 51 | } 52 | 53 | const [plugin] = plugins; 54 | const { handler } = plugin; 55 | 56 | /** 57 | * Calls next plugins recursively. 58 | */ 59 | const next: NextHandler = { 60 | handle: (args) => { 61 | const response = this._handle({ 62 | request: args.request, 63 | plugins: plugins.slice(1), 64 | httpHandler, 65 | }); 66 | return fromSyncOrAsync(response); 67 | }, 68 | }; 69 | 70 | /** 71 | * Handle plugin if plugin's condition tells so. 72 | */ 73 | return this._shouldHandle({ request, plugin }).pipe( 74 | mergeMap(throwIfInvalidPluginCondition), 75 | mergeMap((shouldHandle) => { 76 | if (shouldHandle === false) { 77 | return next.handle({ request }); 78 | } 79 | 80 | return fromSyncOrAsync(handler.handle({ request, next })); 81 | }) 82 | ); 83 | } 84 | 85 | /** 86 | * Tells if the given plugin should be handled or not depending on plugins condition. 87 | */ 88 | private _shouldHandle({ 89 | request, 90 | plugin, 91 | }: { 92 | request: ConvoyrRequest; 93 | plugin: ConvoyrPlugin; 94 | }): Observable { 95 | if ( 96 | plugin.shouldHandleRequest != null && 97 | !isFunction(plugin.shouldHandleRequest) 98 | ) { 99 | return throwError(invalidHandleRequestConditionError()); 100 | } 101 | 102 | if (plugin.shouldHandleRequest == null) { 103 | return of(true); 104 | } 105 | 106 | /** 107 | * The `plugin.shouldHandleRequest` function can synchronously throws an error 108 | * which is not caught in the observable chain without a try catch. 109 | */ 110 | try { 111 | const shouldHandleRequest = plugin.shouldHandleRequest({ request }); 112 | return fromSyncOrAsync(shouldHandleRequest); 113 | } catch (error) { 114 | return throwError(error); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /libs/core/src/lib/handler.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrRequest } from './request'; 2 | import { NextHandler } from './request-handler'; 3 | import { ConvoyrResponse } from './response'; 4 | import { SyncOrAsync } from './utils/from-sync-or-async'; 5 | 6 | export interface PluginHandlerArgs { 7 | request: ConvoyrRequest; 8 | next: NextHandler; 9 | } 10 | 11 | export interface PluginHandler { 12 | handle({ request, next }: PluginHandlerArgs): SyncOrAsync; 13 | } 14 | -------------------------------------------------------------------------------- /libs/core/src/lib/headers.ts: -------------------------------------------------------------------------------- 1 | export interface Headers { 2 | [key: string]: string; 3 | } 4 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/and.spec.ts: -------------------------------------------------------------------------------- 1 | import { readAll } from '@nrwl/angular/testing'; 2 | import { Observable, of } from 'rxjs'; 3 | import { createRequest, RequestArgs } from '../../request'; 4 | import { RequestCondition } from '../../plugin'; 5 | import { matchOrigin, matchMethod } from '..'; 6 | import { and } from './and'; 7 | 8 | describe('operator: and', () => { 9 | it.each< 10 | [ 11 | string, 12 | { 13 | requestArgs: RequestArgs; 14 | operatorArgs: RequestCondition[]; 15 | expected: boolean; 16 | } 17 | ] 18 | >([ 19 | [ 20 | 'match origin and method', 21 | { 22 | requestArgs: { url: 'https://test.com', method: 'GET' }, 23 | operatorArgs: [matchOrigin('https://test.com'), matchMethod('GET')], 24 | expected: true, 25 | }, 26 | ], 27 | [ 28 | 'match origin and custom condition', 29 | { 30 | requestArgs: { 31 | url: 'https://test.com', 32 | method: 'GET', 33 | responseType: 'json', 34 | }, 35 | operatorArgs: [ 36 | matchOrigin('https://test.com'), 37 | ({ request }) => request.responseType === 'json', 38 | ], 39 | expected: true, 40 | }, 41 | ], 42 | [ 43 | 'mismatch origin even if method matches', 44 | { 45 | requestArgs: { url: 'https://wrong.com', method: 'GET' }, 46 | operatorArgs: [matchOrigin('https://test.com'), matchMethod('GET')], 47 | expected: false, 48 | }, 49 | ], 50 | [ 51 | 'mismatch custom condition', 52 | { 53 | requestArgs: { 54 | url: 'https://test.com', 55 | method: 'GET', 56 | responseType: 'arraybuffer', 57 | }, 58 | operatorArgs: [ 59 | matchOrigin('https://test.com'), 60 | ({ request }) => request.responseType === 'json', 61 | ], 62 | expected: false, 63 | }, 64 | ], 65 | [ 66 | 'match with async conditions', 67 | { 68 | requestArgs: { 69 | url: 'https://test.com', 70 | method: 'GET', 71 | }, 72 | operatorArgs: [ 73 | (...args) => of(true), 74 | (...args) => Promise.resolve(true), 75 | ], 76 | expected: true, 77 | }, 78 | ], 79 | [ 80 | 'mismatch with async conditions', 81 | { 82 | requestArgs: { 83 | url: 'https://test.com', 84 | method: 'GET', 85 | }, 86 | operatorArgs: [ 87 | (...args) => of(true), 88 | (...args) => Promise.resolve(false), 89 | ], 90 | expected: false, 91 | }, 92 | ], 93 | ])('should %s', async (name, { requestArgs, operatorArgs, expected }) => { 94 | const request = createRequest({ ...requestArgs }); 95 | expect( 96 | await readAll(and(...operatorArgs)({ request }) as Observable) 97 | ).toEqual([expected]); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/and.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest } from 'rxjs'; 2 | import { map, take } from 'rxjs/operators'; 3 | import { RequestCondition } from '../../plugin'; 4 | import { fromSyncOrAsync } from '../../utils/from-sync-or-async'; 5 | 6 | export const and = (...predicates: RequestCondition[]): RequestCondition => ({ 7 | request, 8 | }) => { 9 | const observableList = predicates.map((predicate) => 10 | fromSyncOrAsync(predicate({ request })) 11 | ); 12 | return combineLatest(observableList).pipe( 13 | take(1), 14 | map((resultList) => resultList.every((value) => value === true)) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/not.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, RequestArgs } from '../../request'; 2 | import { RequestCondition } from '../../plugin'; 3 | import { matchOrigin, matchMethod } from '..'; 4 | import { not } from './not'; 5 | 6 | describe.each<[RequestArgs, RequestCondition, boolean]>([ 7 | [{ url: 'https://test.com', method: 'GET' }, matchMethod('POST'), true], 8 | [ 9 | { url: 'https://test.com', responseType: 'json' }, 10 | matchOrigin('https://wrong.com'), 11 | true, 12 | ], 13 | [ 14 | { url: 'https://test.com', method: 'GET' }, 15 | matchOrigin('https://test.com'), 16 | false, 17 | ], 18 | [ 19 | { url: 'https://test.com', method: 'GET', responseType: 'json' }, 20 | ({ request }) => request.responseType === 'json', 21 | false, 22 | ], 23 | ])('operator: not, index: %#', (requestArgs, requestCondition, expected) => { 24 | it('should returns the opposite of the given matcher', () => { 25 | const request = createRequest({ ...requestArgs }); 26 | expect(not(requestCondition)({ request })).toBe(expected); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/not.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '../../plugin'; 2 | 3 | export const not = (predicate: RequestCondition): RequestCondition => ( 4 | request 5 | ) => !predicate(request); 6 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/or.spec.ts: -------------------------------------------------------------------------------- 1 | import { readAll } from '@nrwl/angular/testing'; 2 | import { Observable, of } from 'rxjs'; 3 | import { matchMethod, matchOrigin } from '..'; 4 | import { RequestCondition } from '../../plugin'; 5 | import { createRequest, RequestArgs } from '../../request'; 6 | import { or } from './or'; 7 | 8 | describe('operator: or', () => { 9 | it.each< 10 | [ 11 | string, 12 | { 13 | requestArgs: RequestArgs; 14 | operatorArgs: RequestCondition[]; 15 | expected: boolean; 16 | } 17 | ] 18 | >([ 19 | [ 20 | 'match method while origin mismatch', 21 | { 22 | requestArgs: { url: 'https://wrong.com', method: 'GET' }, 23 | operatorArgs: [matchOrigin('https://test.com'), matchMethod('GET')], 24 | expected: true, 25 | }, 26 | ], 27 | [ 28 | 'match custom condition while origin mismatch', 29 | { 30 | requestArgs: { 31 | url: 'https://wrong.com', 32 | method: 'GET', 33 | responseType: 'json', 34 | }, 35 | operatorArgs: [ 36 | matchOrigin('https://test.com'), 37 | ({ request }) => request.responseType === 'json', 38 | ], 39 | expected: true, 40 | }, 41 | ], 42 | [ 43 | 'mismatch because nothing matches', 44 | { 45 | requestArgs: { url: 'https://wrong.com', method: 'POST' }, 46 | operatorArgs: [matchOrigin('https://test.com'), matchMethod('GET')], 47 | expected: false, 48 | }, 49 | ], 50 | [ 51 | 'mismatch custom condition', 52 | { 53 | requestArgs: { 54 | url: 'https://wrong.com', 55 | method: 'GET', 56 | responseType: 'arraybuffer', 57 | }, 58 | operatorArgs: [ 59 | matchOrigin('https://test.com'), 60 | ({ request }) => request.responseType === 'json', 61 | ], 62 | expected: false, 63 | }, 64 | ], 65 | [ 66 | 'match with async conditions', 67 | { 68 | requestArgs: { 69 | url: 'https://test.com', 70 | method: 'GET', 71 | }, 72 | operatorArgs: [ 73 | (...args) => of(true), 74 | (...args) => Promise.resolve(false), 75 | ], 76 | expected: true, 77 | }, 78 | ], 79 | [ 80 | 'mismatch with async conditions', 81 | { 82 | requestArgs: { 83 | url: 'https://test.com', 84 | method: 'GET', 85 | }, 86 | operatorArgs: [ 87 | (...args) => of(false), 88 | (...args) => Promise.resolve(false), 89 | ], 90 | expected: false, 91 | }, 92 | ], 93 | ])('should %s', async (name, { requestArgs, operatorArgs, expected }) => { 94 | const request = createRequest({ ...requestArgs }); 95 | expect( 96 | await readAll(or(...operatorArgs)({ request }) as Observable) 97 | ).toEqual([expected]); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/combiners/or.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest } from 'rxjs'; 2 | import { map, take } from 'rxjs/operators'; 3 | import { RequestCondition } from '../../plugin'; 4 | import { fromSyncOrAsync } from '../../utils/from-sync-or-async'; 5 | 6 | export const or = (...predicates: RequestCondition[]): RequestCondition => ({ 7 | request, 8 | }) => { 9 | const observableList = predicates.map((predicate) => 10 | fromSyncOrAsync(predicate({ request })) 11 | ); 12 | return combineLatest(observableList).pipe( 13 | take(1), 14 | map((resultList) => resultList.some((value) => value === true)) 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/find-matcher-or-throw.ts: -------------------------------------------------------------------------------- 1 | import { Matcher } from './matcher'; 2 | 3 | export function invalidMatchExpressionError(matchExpression) { 4 | return new Error( 5 | `InvalidMatchExpression: ${JSON.stringify( 6 | matchExpression 7 | )} is not a valid match expression.` 8 | ); 9 | } 10 | 11 | export function findMatcherOrThrow({ 12 | matchExpression, 13 | matcherList, 14 | matcherError = invalidMatchExpressionError(matchExpression), 15 | }: { 16 | matchExpression: any; 17 | matcherList: Matcher[]; 18 | matcherError: Error; 19 | }): Matcher { 20 | const matcher = matcherList.find((_matcher) => 21 | _matcher.canHandle(matchExpression) 22 | ); 23 | 24 | if (matcher == null) { 25 | throw matcherError; 26 | } 27 | 28 | return matcher; 29 | } 30 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/index.ts: -------------------------------------------------------------------------------- 1 | export { matchOrigin } from './match-origin/match-origin'; 2 | export { matchMethod } from './match-method/match-method'; 3 | export { matchResponseType } from './match-response-type/match-response-type'; 4 | export { matchPath } from './match-path/match-path'; 5 | export { and } from './combiners/and'; 6 | export { or } from './combiners/or'; 7 | export { not } from './combiners/not'; 8 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/invalid-method-match-expression.ts: -------------------------------------------------------------------------------- 1 | export function invalidMethodMatchExpression(matchExpression) { 2 | return new Error( 3 | `InvalidMethodMatchExpression: ${JSON.stringify( 4 | matchExpression 5 | )} is an invalid method match expression.` 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/match-method-expression.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../../request'; 2 | import { Matcher } from '../matcher'; 3 | 4 | export type MatchMethodExpression = HttpMethod | HttpMethod[]; 5 | export type MethodMatcher = Matcher; 6 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/match-method.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, ConvoyrRequest, HttpMethod } from '../../request'; 2 | import { matchMethod } from './match-method'; 3 | import { MatchMethodExpression } from './match-method-expression'; 4 | 5 | describe.each<[HttpMethod, MatchMethodExpression, boolean]>([ 6 | ['GET', 'GET', true], 7 | ['GET', 'POST', false], 8 | ['GET', ['GET', 'PUT'], true], 9 | ['GET', ['PUT', 'POST'], false], 10 | ])( 11 | 'matchMethod with method: %p and matcher: %p => %p', 12 | (method, matchExpression, expected) => { 13 | it('should check method', () => { 14 | const request = createRequest({ url: 'https://test.com', method }); 15 | expect(matchMethod(matchExpression)({ request })).toBe(expected); 16 | }); 17 | } 18 | ); 19 | 20 | describe('matchMethod', () => { 21 | let request: ConvoyrRequest; 22 | 23 | beforeEach(() => (request = createRequest({ url: 'https://test.com' }))); 24 | 25 | it('should throw when given an object', () => { 26 | expect(() => matchMethod({} as any)({ request })).toThrow( 27 | 'InvalidMethodMatchExpression: {} is an invalid method match expression.' 28 | ); 29 | }); 30 | 31 | it('should throw when given an number', () => { 32 | expect(() => matchMethod(123 as any)({ request })).toThrow( 33 | 'InvalidMethodMatchExpression: 123 is an invalid method match expression.' 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/match-method.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '../../plugin'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { invalidMethodMatchExpression } from './invalid-method-match-expression'; 4 | import { MatchMethodExpression } from './match-method-expression'; 5 | import { methodArrayMatcher } from './method-array-matcher'; 6 | import { methodStringMatcher } from './method-string-matcher'; 7 | 8 | export const matchMethod = ( 9 | matchExpression: MatchMethodExpression 10 | ): RequestCondition => ({ request }): boolean => { 11 | const { method } = request; 12 | const matcher = findMatcherOrThrow({ 13 | matchExpression: matchExpression, 14 | matcherList: [methodStringMatcher, methodArrayMatcher], 15 | matcherError: invalidMethodMatchExpression(matchExpression), 16 | }); 17 | 18 | return matcher.handle({ 19 | matchExpression, 20 | value: method, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/method-array-matcher.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../../request'; 2 | import { isArray } from '../../utils/is-array'; 3 | import { MethodMatcher } from './match-method-expression'; 4 | 5 | export const methodArrayMatcher: MethodMatcher = { 6 | canHandle(matchExpression: HttpMethod[]) { 7 | return isArray(matchExpression); 8 | }, 9 | handle({ 10 | value, 11 | matchExpression, 12 | }: { 13 | value: HttpMethod; 14 | matchExpression: HttpMethod[]; 15 | }) { 16 | return matchExpression.includes(value); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-method/method-string-matcher.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../../request'; 2 | import { isString } from '../../utils/is-string'; 3 | import { MethodMatcher } from './match-method-expression'; 4 | 5 | export const methodStringMatcher: MethodMatcher = { 6 | canHandle(matchExpression) { 7 | return isString(matchExpression); 8 | }, 9 | handle({ 10 | value, 11 | matchExpression, 12 | }: { 13 | value: HttpMethod; 14 | matchExpression: HttpMethod; 15 | }) { 16 | return value === matchExpression; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/get-origin.spec.ts: -------------------------------------------------------------------------------- 1 | import { getOrigin } from './get-origin'; 2 | 3 | describe('getOrigin', () => { 4 | it('should extract origin from URL', () => { 5 | expect(getOrigin('https://jscutlery.github.io')).toEqual( 6 | 'https://jscutlery.github.io' 7 | ); 8 | expect(getOrigin('https://jscutlery.github.io/test')).toEqual( 9 | 'https://jscutlery.github.io' 10 | ); 11 | expect(getOrigin('https://jscutlery.github.io:443/test')).toEqual( 12 | 'https://jscutlery.github.io:443' 13 | ); 14 | }); 15 | 16 | it('should fail if invalid URL', () => { 17 | expect(() => getOrigin('jscutlery.github.io')).toThrow( 18 | 'InvalidUrlError: jscutlery.github.io is not a valid URL.' 19 | ); 20 | expect(() => getOrigin('jscutlery.github.io:443')).toThrow( 21 | 'InvalidUrlError: jscutlery.github.io:443 is not a valid URL.' 22 | ); 23 | expect(() => getOrigin('/test')).toThrow( 24 | 'InvalidUrlError: /test is not a valid URL.' 25 | ); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/get-origin.ts: -------------------------------------------------------------------------------- 1 | export function invalidUrlError(url: string) { 2 | return new Error(`InvalidUrlError: ${url} is not a valid URL.`); 3 | } 4 | 5 | export const getOrigin = (url: string): string => { 6 | const [scheme, urlWithoutScheme] = url.split('://'); 7 | 8 | if (scheme == null || urlWithoutScheme == null) { 9 | throw invalidUrlError(url); 10 | } 11 | 12 | const result = urlWithoutScheme.match(/^[a-z0-9.:-]+/); 13 | 14 | if (result == null) { 15 | throw invalidUrlError(url); 16 | } 17 | 18 | const originWithoutScheme = result[0]; 19 | 20 | return `${scheme}://${originWithoutScheme}`; 21 | }; 22 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/invalid-origin-match-expression.ts: -------------------------------------------------------------------------------- 1 | export function invalidOriginMatchExpression(matchExpression) { 2 | return new Error( 3 | `InvalidOriginMatchExpression: ${JSON.stringify( 4 | matchExpression 5 | )} is an invalid origin match expression.` 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/match-origin.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, ConvoyrRequest } from '../../request'; 2 | import { matchOrigin } from './match-origin'; 3 | import { OriginMatchExpression } from './origin-match-expression'; 4 | 5 | describe.each<[string, OriginMatchExpression, boolean]>([ 6 | /* Using a string */ 7 | ['https://test.com', 'https://test.com', true], 8 | ['https://test.com', 'https://angular.io', false], 9 | ['https://test.com/test', 'https://test.com', true], 10 | ['https://test.com?length=1', 'https://test.com', true], 11 | ['https://test.com.test.com', 'https://test.com', false], 12 | ['https://test.com.test.com/test', 'https://test.com', false], 13 | ['https://test.com.test.com?length=1', 'https://test.com', false], 14 | 15 | /* Using an Array */ 16 | ['https://test.com', ['https://test.com'], true], 17 | ['https://test.com/test', ['https://test.com'], true], 18 | ['https://test.com', ['https://angular.io'], false], 19 | 20 | /* Using a RegExp */ 21 | ['https://test.com', /[a-z]/, true], 22 | ['https://test.com', /[0-9]/, false], 23 | ])( 24 | 'matchOrigin with url: %p and matcher: %p => %p', 25 | (url, matcher, expected) => { 26 | it('should check origin', () => { 27 | const request = createRequest({ url }); 28 | expect(matchOrigin(matcher)({ request })).toBe(expected); 29 | }); 30 | } 31 | ); 32 | 33 | describe.each<[string, boolean]>([ 34 | ['https://test.com', true], 35 | ['http://test.com', false], 36 | ])( 37 | 'matchOrigin with url: %p and starts with https predicate => %p', 38 | (url, expected) => { 39 | const startsWithHttpsPredicate = (origin: string) => 40 | origin.startsWith('https://'); 41 | 42 | it('should check origin', () => { 43 | const request = createRequest({ url }); 44 | expect(matchOrigin(startsWithHttpsPredicate)({ request })).toBe(expected); 45 | }); 46 | } 47 | ); 48 | 49 | describe('matchOrigin', () => { 50 | let request: ConvoyrRequest; 51 | 52 | beforeEach(() => (request = createRequest({ url: 'https://test.com' }))); 53 | 54 | it('should throw when given an object', () => { 55 | expect(() => matchOrigin({} as any)({ request })).toThrow( 56 | 'InvalidOriginMatchExpression: {} is an invalid origin match expression.' 57 | ); 58 | }); 59 | 60 | it('should throw when given an number', () => { 61 | expect(() => matchOrigin(123 as any)({ request })).toThrow( 62 | 'InvalidOriginMatchExpression: 123 is an invalid origin match expression.' 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/match-origin.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '../../plugin'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { getOrigin } from './get-origin'; 4 | import { invalidOriginMatchExpression } from './invalid-origin-match-expression'; 5 | import { originArrayMatcher } from './origin-array-matcher'; 6 | import { OriginMatchExpression } from './origin-match-expression'; 7 | import { originPredicateMatcher } from './origin-predicate-matcher'; 8 | import { originRegExpMatcher } from './origin-reg-exp-matcher'; 9 | import { originStringMatcher } from './origin-string-matcher'; 10 | 11 | export const matchOrigin = ( 12 | matchExpression: OriginMatchExpression 13 | ): RequestCondition => ({ request }): boolean => { 14 | const origin = getOrigin(request.url); 15 | const matcher = findMatcherOrThrow({ 16 | matchExpression: matchExpression, 17 | matcherList: [ 18 | originStringMatcher, 19 | originArrayMatcher, 20 | originRegExpMatcher, 21 | originPredicateMatcher, 22 | ], 23 | matcherError: invalidOriginMatchExpression(matchExpression), 24 | }); 25 | 26 | return matcher.handle({ 27 | matchExpression, 28 | value: origin, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/origin-array-matcher.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from '../../utils/is-array'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { Matcher } from '../matcher'; 4 | import { invalidOriginMatchExpression } from './invalid-origin-match-expression'; 5 | import { OriginMatcher } from './origin-match-expression'; 6 | import { originStringMatcher } from './origin-string-matcher'; 7 | 8 | export const originArrayMatcher: Matcher = { 9 | canHandle(matchExpression) { 10 | return isArray(matchExpression); 11 | }, 12 | handle({ value, matchExpression }) { 13 | return matchExpression.some((childExpression) => { 14 | const matcher = findMatcherOrThrow({ 15 | matchExpression: childExpression, 16 | matcherList: [originStringMatcher], 17 | matcherError: invalidOriginMatchExpression(matchExpression), 18 | }); 19 | 20 | return matcher.handle({ 21 | value, 22 | matchExpression: childExpression, 23 | }); 24 | }); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/origin-match-expression.ts: -------------------------------------------------------------------------------- 1 | import { Matcher } from '../matcher'; 2 | import { MatchOriginPredicate } from './origin-predicate-matcher'; 3 | 4 | export type OriginMatchExpression = 5 | | string 6 | | string[] 7 | | RegExp 8 | | MatchOriginPredicate; 9 | export type OriginMatcher = Matcher; 10 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/origin-predicate-matcher.ts: -------------------------------------------------------------------------------- 1 | import { isFunction } from '../../utils/is-function'; 2 | import { OriginMatcher } from './origin-match-expression'; 3 | 4 | export type MatchOriginPredicate = (origin: string) => boolean; 5 | 6 | export const originPredicateMatcher: OriginMatcher = { 7 | canHandle(matchExpression) { 8 | return isFunction(matchExpression); 9 | }, 10 | handle({ 11 | value, 12 | matchExpression 13 | }: { 14 | value: string; 15 | matchExpression: MatchOriginPredicate; 16 | }) { 17 | return matchExpression(value); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/origin-reg-exp-matcher.ts: -------------------------------------------------------------------------------- 1 | import { OriginMatcher } from './origin-match-expression'; 2 | 3 | export const originRegExpMatcher: OriginMatcher = { 4 | canHandle(matchExpression) { 5 | return matchExpression instanceof RegExp; 6 | }, 7 | handle({ 8 | value, 9 | matchExpression 10 | }: { 11 | value: string; 12 | matchExpression: RegExp; 13 | }) { 14 | return matchExpression.test(value); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-origin/origin-string-matcher.ts: -------------------------------------------------------------------------------- 1 | import { isString } from '../../utils/is-string'; 2 | import { OriginMatcher } from './origin-match-expression'; 3 | 4 | export const originStringMatcher: OriginMatcher = { 5 | canHandle(matchExpression) { 6 | return isString(matchExpression); 7 | }, 8 | handle({ 9 | value, 10 | matchExpression, 11 | }: { 12 | matchExpression: string; 13 | value: string; 14 | }) { 15 | return value === matchExpression; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-path/invalid-path-match-expression.ts: -------------------------------------------------------------------------------- 1 | export function invalidPathExpression(matchExpression) { 2 | return new Error( 3 | `InvalidPathMatchExpression: ${JSON.stringify( 4 | matchExpression 5 | )} is an invalid path expression.` 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-path/match-path-expression.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethod } from '../../request'; 2 | import { Matcher } from '../matcher'; 3 | 4 | export type PathMatcher = Matcher; 5 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-path/match-path.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, ConvoyrRequest, HttpMethod } from '../../request'; 2 | import { matchPath } from './match-path'; 3 | 4 | describe.each<[string, string, boolean]>([ 5 | ['/api/test', '/api/test', true], 6 | ['/api/test/', '/api/test/', true], 7 | 8 | /* It should remove trailing slash */ 9 | ['/api/test', '/api/test/', true], 10 | ['/api/test/', '/api/test', true], 11 | 12 | ['/api/test/nested', '/api/test/', false], 13 | ['/api/test/nested', '/api/test', false], 14 | ])( 15 | 'matchPath with path: %p and matcher: %p => %p', 16 | (path, matchExpression, expected) => { 17 | it('should check path', () => { 18 | const request = createRequest({ url: 'https://test.com' + path }); 19 | expect(matchPath(matchExpression)({ request })).toBe(expected); 20 | }); 21 | } 22 | ); 23 | 24 | describe('matchPath', () => { 25 | let request: ConvoyrRequest; 26 | 27 | beforeEach(() => (request = createRequest({ url: 'https://test.com' }))); 28 | 29 | it('should throw when given an object', () => { 30 | expect(() => matchPath({} as any)({ request })).toThrow( 31 | 'InvalidPathMatchExpression: {} is an invalid path expression.' 32 | ); 33 | }); 34 | 35 | it('should throw when given an number', () => { 36 | expect(() => matchPath(123 as any)({ request })).toThrow( 37 | 'InvalidPathMatchExpression: 123 is an invalid path expression.' 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-path/match-path.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '../../plugin'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { invalidPathExpression } from './invalid-path-match-expression'; 4 | import { pathStringMatcher } from './method-string-matcher'; 5 | 6 | export const matchPath = (matchExpression: string): RequestCondition => ({ 7 | request, 8 | }): boolean => { 9 | const { url } = request; 10 | const { pathname } = new URL(url); 11 | 12 | const matcher = findMatcherOrThrow({ 13 | matchExpression: matchExpression, 14 | matcherList: [pathStringMatcher], 15 | matcherError: invalidPathExpression(matchExpression), 16 | }); 17 | 18 | return matcher.handle({ 19 | matchExpression, 20 | value: pathname, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-path/method-string-matcher.ts: -------------------------------------------------------------------------------- 1 | import { isString } from '../../utils/is-string'; 2 | import { PathMatcher } from './match-path-expression'; 3 | 4 | export const removeTrailingSlash = (expression: string): string => { 5 | if (expression.endsWith('/')) { 6 | return expression.substring(0, expression.length - 1); 7 | } 8 | 9 | return expression; 10 | }; 11 | 12 | export const pathStringMatcher: PathMatcher = { 13 | canHandle(matchExpression) { 14 | return isString(matchExpression); 15 | }, 16 | handle({ 17 | value, 18 | matchExpression, 19 | }: { 20 | value: string; 21 | matchExpression: string; 22 | }) { 23 | return removeTrailingSlash(value) === removeTrailingSlash(matchExpression); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/invalid-response-type-match-expression.ts: -------------------------------------------------------------------------------- 1 | export function invalidResponseTypeMatchExpression(matchExpression) { 2 | return new Error( 3 | `InvalidResponseTypeMatchExpression: ${JSON.stringify( 4 | matchExpression 5 | )} is an invalid origin match expression.` 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/match-response-type-expression.ts: -------------------------------------------------------------------------------- 1 | import { Matcher } from '../matcher'; 2 | import { ResponseType } from '../../request'; 3 | 4 | export type ResponseTypeMatchExpression = ResponseType | ResponseType[]; 5 | export type ResponseTypeMatcher = Matcher< 6 | ResponseTypeMatchExpression, 7 | ResponseType 8 | >; 9 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/match-response-type.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrRequest, createRequest, ResponseType } from '../../request'; 2 | import { matchResponseType } from './match-response-type'; 3 | import { ResponseTypeMatchExpression } from './match-response-type-expression'; 4 | 5 | describe.each<[ResponseType, ResponseTypeMatchExpression, boolean]>([ 6 | /* Using a string */ 7 | ['json', 'json', true], 8 | ['json', 'blob', false], 9 | 10 | /* Using an Array */ 11 | ['text', ['text', 'arraybuffer'], true], 12 | ['arraybuffer', ['text'], false], 13 | ])( 14 | 'matchResponseType with type: %p and matcher: %p => %p', 15 | (responseType, matcher, expected) => { 16 | it('should check response type', () => { 17 | const request = createRequest({ url: 'https://test.com', responseType }); 18 | expect(matchResponseType(matcher)({ request })).toBe(expected); 19 | }); 20 | } 21 | ); 22 | 23 | describe('matchResponseType', () => { 24 | let request: ConvoyrRequest; 25 | 26 | beforeEach(() => (request = createRequest({ url: 'https://test.com' }))); 27 | 28 | it('should throw when given an object', () => { 29 | expect(() => matchResponseType({} as any)({ request })).toThrow( 30 | 'InvalidResponseTypeMatchExpression: {} is an invalid origin match expression.' 31 | ); 32 | }); 33 | 34 | it('should throw when given an number', () => { 35 | expect(() => matchResponseType(123 as any)({ request })).toThrow( 36 | 'InvalidResponseTypeMatchExpression: 123 is an invalid origin match expression.' 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/match-response-type.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '../../plugin'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { invalidResponseTypeMatchExpression } from './invalid-response-type-match-expression'; 4 | import { ResponseTypeMatchExpression } from './match-response-type-expression'; 5 | import { responseTypeArrayMatcher } from './response-type-array-matcher'; 6 | import { responseTypeStringMatcher } from './response-type-string-matcher'; 7 | 8 | export const matchResponseType = ( 9 | matchExpression: ResponseTypeMatchExpression 10 | ): RequestCondition => ({ request }): boolean => { 11 | const matcher = findMatcherOrThrow({ 12 | matchExpression: matchExpression, 13 | matcherList: [responseTypeStringMatcher, responseTypeArrayMatcher], 14 | matcherError: invalidResponseTypeMatchExpression(matchExpression), 15 | }); 16 | 17 | return matcher.handle({ 18 | matchExpression, 19 | value: request.responseType, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/response-type-array-matcher.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from '../../utils/is-array'; 2 | import { findMatcherOrThrow } from '../find-matcher-or-throw'; 3 | import { Matcher } from '../matcher'; 4 | import { invalidResponseTypeMatchExpression } from './invalid-response-type-match-expression'; 5 | import { ResponseTypeMatcher } from './match-response-type-expression'; 6 | import { responseTypeStringMatcher } from './response-type-string-matcher'; 7 | 8 | export const responseTypeArrayMatcher: Matcher = { 9 | canHandle(matchExpression) { 10 | return isArray(matchExpression); 11 | }, 12 | handle({ value, matchExpression }) { 13 | return matchExpression.some((childExpression) => { 14 | const matcher = findMatcherOrThrow({ 15 | matchExpression: childExpression, 16 | matcherList: [responseTypeStringMatcher], 17 | matcherError: invalidResponseTypeMatchExpression(matchExpression), 18 | }); 19 | 20 | return matcher.handle({ 21 | value, 22 | matchExpression: childExpression, 23 | }); 24 | }); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/match-response-type/response-type-string-matcher.ts: -------------------------------------------------------------------------------- 1 | import { ResponseType } from '../../request'; 2 | import { isString } from '../../utils/is-string'; 3 | import { ResponseTypeMatcher } from './match-response-type-expression'; 4 | 5 | export const responseTypeStringMatcher: ResponseTypeMatcher = { 6 | canHandle(matchExpression) { 7 | return isString(matchExpression); 8 | }, 9 | handle({ 10 | value, 11 | matchExpression, 12 | }: { 13 | matchExpression: ResponseType; 14 | value: ResponseType; 15 | }) { 16 | return value === matchExpression; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /libs/core/src/lib/matchers/matcher.ts: -------------------------------------------------------------------------------- 1 | export interface Matcher { 2 | canHandle(matchExpression: TMatchExpression): boolean; 3 | handle(args: { matchExpression: TMatchExpression; value: TValue }): boolean; 4 | } 5 | -------------------------------------------------------------------------------- /libs/core/src/lib/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import { ConvoyrRequest } from './request'; 4 | import { PluginHandler } from './handler'; 5 | 6 | export type RequestCondition = ({ 7 | request, 8 | }: { 9 | request: ConvoyrRequest; 10 | }) => boolean | Promise | Observable; 11 | 12 | export interface ConvoyrPlugin { 13 | shouldHandleRequest?: RequestCondition; 14 | handler: PluginHandler; 15 | } 16 | -------------------------------------------------------------------------------- /libs/core/src/lib/request-handler.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import { ConvoyrResponse } from './response'; 4 | import { ConvoyrRequest } from './request'; 5 | 6 | export interface NextHandler { 7 | handle({ request }: { request: ConvoyrRequest }): Observable; 8 | } 9 | -------------------------------------------------------------------------------- /libs/core/src/lib/request.ts: -------------------------------------------------------------------------------- 1 | import { Headers } from './headers'; 2 | 3 | export type ResponseType = 'arraybuffer' | 'blob' | 'json' | 'text'; 4 | 5 | export type HttpMethod = 6 | | 'HEAD' 7 | | 'OPTIONS' 8 | | 'GET' 9 | | 'POST' 10 | | 'PUT' 11 | | 'PATCH' 12 | | 'DELETE'; 13 | 14 | export interface ConvoyrRequest { 15 | readonly url: string; 16 | readonly method: HttpMethod; 17 | readonly body: TBody | null; 18 | readonly headers: Headers; 19 | readonly params: { [key: string]: string | string[] }; 20 | readonly responseType: ResponseType; 21 | } 22 | 23 | export type RequestArgs = { url: string } & Partial< 24 | ConvoyrRequest 25 | >; 26 | 27 | export function createRequest( 28 | request: RequestArgs 29 | ): ConvoyrRequest { 30 | return { 31 | url: request.url, 32 | method: request.method || 'GET', 33 | body: request.body || null, 34 | headers: request.headers || {}, 35 | params: request.params || {}, 36 | responseType: request.responseType || 'json', 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /libs/core/src/lib/response.ts: -------------------------------------------------------------------------------- 1 | import { Headers } from './headers'; 2 | 3 | export interface ConvoyrResponse { 4 | body: TBody; 5 | status: number; 6 | statusText: string; 7 | headers: Headers; 8 | } 9 | 10 | export type ResponseArgs = Partial> & 11 | ({ body: TBody } | { status: number; statusText: string }); 12 | 13 | export function createResponse( 14 | response: ResponseArgs 15 | ): ConvoyrResponse { 16 | return { 17 | body: response.body, 18 | status: response.status == null ? 200 : response.status, 19 | statusText: response.statusText || 'OK', 20 | headers: response.headers || {}, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /libs/core/src/lib/throw-invalid-plugin-condition.ts: -------------------------------------------------------------------------------- 1 | import { iif, Observable, of, throwError } from 'rxjs'; 2 | import { isBoolean } from './utils/is-boolean'; 3 | 4 | export function invalidPluginConditionError(type: string) { 5 | return new TypeError( 6 | `InvalidPluginConditionError: expect boolean got ${type}.` 7 | ); 8 | } 9 | 10 | export const throwIfInvalidPluginCondition = ( 11 | condition: boolean 12 | ): Observable => 13 | iif( 14 | () => isBoolean(condition), 15 | of(condition), 16 | throwError(invalidPluginConditionError(typeof condition)) 17 | ); 18 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/from-sync-or-async.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | 3 | import { fromSyncOrAsync } from './from-sync-or-async'; 4 | 5 | describe('fromSyncOrAsync', () => { 6 | it('should convert a string to an Observable of string', async () => { 7 | const string = await fromSyncOrAsync('42').toPromise(); 8 | expect(string).toBe('42'); 9 | }); 10 | 11 | it('should convert a number to an Observable of number', async () => { 12 | const number = await fromSyncOrAsync(42).toPromise(); 13 | expect(number).toBe(42); 14 | }); 15 | 16 | it('should convert undefined to an Observable of undefined', async () => { 17 | const notDefined = await fromSyncOrAsync(undefined).toPromise(); 18 | expect(notDefined).toBe(undefined); 19 | }); 20 | 21 | it('should convert an object to an Observable of object', async () => { 22 | const object = await fromSyncOrAsync({ value: 42 }).toPromise(); 23 | expect(object).toEqual({ value: 42 }); 24 | }); 25 | 26 | it('should convert a Promise to an Observable', async () => { 27 | const promise = new Promise(resolve => resolve(42)); 28 | const object = await fromSyncOrAsync(promise).toPromise(); 29 | expect(object).toEqual(42); 30 | }); 31 | 32 | it('should return the Observable as is', async () => { 33 | const observable$ = of(42); 34 | const object = await fromSyncOrAsync(observable$).toPromise(); 35 | expect(object).toEqual(42); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/from-sync-or-async.ts: -------------------------------------------------------------------------------- 1 | import { 2 | from, 3 | isObservable, 4 | Observable, 5 | of, 6 | SubscribableOrPromise 7 | } from 'rxjs'; 8 | 9 | import { isPromise } from './is-promise'; 10 | 11 | export type SyncOrAsync = T | SubscribableOrPromise; 12 | 13 | export function fromSyncOrAsync(value: SyncOrAsync): Observable { 14 | if (isObservable(value)) { 15 | return value; 16 | } 17 | if (isPromise(value)) { 18 | return from(value); 19 | } 20 | return of(value as T); 21 | } 22 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-array.ts: -------------------------------------------------------------------------------- 1 | export const isArray = (value: any): value is any[] => Array.isArray(value); 2 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-boolean.ts: -------------------------------------------------------------------------------- 1 | import { isTypeof } from './is-typeof'; 2 | 3 | export const isBoolean = (value: any): value is boolean => 4 | isTypeof('boolean')(value); 5 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-function.ts: -------------------------------------------------------------------------------- 1 | import { isTypeof } from './is-typeof'; 2 | 3 | export const isFunction = (value: any): value is Function => 4 | isTypeof('function')(value); 5 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-promise.ts: -------------------------------------------------------------------------------- 1 | export function isPromise(value: any): value is Promise { 2 | return typeof value?.then === 'function'; 3 | } 4 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-string.ts: -------------------------------------------------------------------------------- 1 | import { isTypeof } from './is-typeof'; 2 | 3 | export const isString = (value: any): value is string => 4 | isTypeof('string')(value); 5 | -------------------------------------------------------------------------------- /libs/core/src/lib/utils/is-typeof.ts: -------------------------------------------------------------------------------- 1 | type PrimitiveType = 2 | | 'string' 3 | | 'number' 4 | | 'bigint' 5 | | 'boolean' 6 | | 'function' 7 | | 'object' 8 | | 'symbol' 9 | | 'undefined'; 10 | 11 | export function isTypeof(type: PrimitiveType): (value: any) => boolean { 12 | return (value: any): boolean => { 13 | return typeof value === type; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /libs/core/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/core/testing/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", 3 | "lib": { 4 | "entryFile": "src/index.ts" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /libs/core/testing/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createSpyPlugin, SpyPlugin } from './lib/create-spy-plugin'; 2 | export { 3 | createPluginTester, 4 | PluginTester, 5 | TestResponse, 6 | PluginTesterArgs, 7 | } from './lib/create-plugin-tester'; 8 | -------------------------------------------------------------------------------- /libs/core/testing/src/lib/create-plugin-tester.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRequest, createResponse } from '@convoyr/core'; 2 | import { isObservable } from 'rxjs'; 3 | import { createPluginTester, PluginTester } from './create-plugin-tester'; 4 | import { createSpyPlugin } from './create-spy-plugin'; 5 | 6 | describe('PluginTester', () => { 7 | const request = createRequest({ url: 'http://test.com' }); 8 | const spyPlugin = createSpyPlugin(); 9 | 10 | let pluginTester: PluginTester; 11 | beforeEach(() => (pluginTester = createPluginTester({ plugin: spyPlugin }))); 12 | 13 | it('should mock the Http handler with a default ok response', async () => { 14 | const httpHandlerMock = pluginTester.createHttpHandlerMock(); 15 | 16 | const httpResponse$ = httpHandlerMock(); 17 | 18 | expect(jest.isMockFunction(httpHandlerMock)).toBeTruthy(); 19 | expect(isObservable(httpResponse$)).toBeTruthy(); 20 | expect(await httpResponse$.toPromise()).toEqual( 21 | createResponse({ status: 200, statusText: 'ok' }) 22 | ); 23 | }); 24 | 25 | it('should run the plugin correctly', async () => { 26 | const httpHandlerMock = pluginTester.createHttpHandlerMock({ 27 | response: createResponse({ body: 'Edward Whymper' }), 28 | }); 29 | 30 | const response$ = pluginTester.handleFake({ 31 | request, 32 | httpHandlerMock, 33 | }); 34 | 35 | expect(isObservable(response$)).toBeTruthy(); 36 | 37 | const response = await response$.toPromise(); 38 | 39 | expect(spyPlugin.shouldHandleRequest).toHaveBeenCalledTimes(1); 40 | expect(spyPlugin.shouldHandleRequest.mock.calls[0][0]).toEqual({ 41 | request, 42 | }); 43 | expect(spyPlugin.handler.handle).toBeCalledTimes(1); 44 | expect(spyPlugin.handler.handle.mock.calls[0][0]).toEqual({ 45 | next: { handle: expect.any(Function) }, 46 | request, 47 | }); 48 | expect(response).toEqual( 49 | expect.objectContaining({ 50 | body: 'Edward Whymper', 51 | }) 52 | ); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /libs/core/testing/src/lib/create-plugin-tester.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Convoyr, 3 | ConvoyrPlugin, 4 | ConvoyrRequest, 5 | ConvoyrResponse, 6 | createResponse, 7 | } from '@convoyr/core'; 8 | import { Observable } from 'rxjs'; 9 | import { fromSyncOrAsync } from '@convoyr/core'; 10 | 11 | export type TestResponse = ConvoyrResponse | Observable; 12 | 13 | export interface PluginTesterArgs { 14 | plugin: ConvoyrPlugin; 15 | } 16 | 17 | export function createPluginTester({ plugin }: PluginTesterArgs) { 18 | return new PluginTester({ plugin }); 19 | } 20 | 21 | export class PluginTester { 22 | private _convoyr: Convoyr; 23 | 24 | constructor({ plugin }: PluginTesterArgs) { 25 | this._convoyr = this._createConvoyr(plugin); 26 | } 27 | 28 | createHttpHandlerMock({ 29 | response = createResponse({ status: 200, statusText: 'ok' }), 30 | }: { 31 | response?: TestResponse; 32 | } = {}): jest.Mock>> { 33 | return jest.fn(() => fromSyncOrAsync(response)); 34 | } 35 | 36 | handleFake({ 37 | request, 38 | httpHandlerMock, 39 | }: { 40 | request: ConvoyrRequest; 41 | httpHandlerMock: jest.Mock>; 42 | }): Observable { 43 | return this._convoyr.handle({ 44 | request, 45 | httpHandler: { handle: httpHandlerMock }, 46 | }); 47 | } 48 | 49 | private _createConvoyr(plugin: ConvoyrPlugin): Convoyr { 50 | const convoyr = new Convoyr({ 51 | plugins: [plugin], 52 | }); 53 | 54 | return convoyr; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /libs/core/testing/src/lib/create-spy-plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { SpyPlugin, createSpyPlugin } from './create-spy-plugin'; 2 | import { createRequest, createResponse } from '@convoyr/core'; 3 | import { of } from 'rxjs'; 4 | 5 | describe('createSpyPlugin', () => { 6 | let spyPlugin: SpyPlugin; 7 | beforeEach(() => { 8 | spyPlugin = createSpyPlugin(); 9 | }); 10 | 11 | it('should create a spy plugin that just pass through the next plugin', () => { 12 | const request = createRequest({ url: 'test' }); 13 | const nextSpy = { 14 | handle: jest.fn(() => of(createResponse({ body: null }))), 15 | }; 16 | 17 | expect(jest.isMockFunction(spyPlugin.shouldHandleRequest)).toBeTruthy(); 18 | expect(jest.isMockFunction(spyPlugin.handler.handle)).toBeTruthy(); 19 | 20 | spyPlugin.handler.handle({ request, next: nextSpy }); 21 | 22 | expect(nextSpy.handle).toHaveBeenCalledTimes(1); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /libs/core/testing/src/lib/create-spy-plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConvoyrResponse, 3 | ConvoyrRequest, 4 | NextHandler, 5 | RequestCondition, 6 | } from '@convoyr/core'; 7 | import { Observable } from 'rxjs'; 8 | 9 | export interface SpyPlugin { 10 | shouldHandleRequest: jest.Mock< 11 | ReturnType, 12 | [{ request: ConvoyrRequest }] 13 | >; 14 | handler: { 15 | handle: jest.Mock< 16 | Observable, 17 | [{ request: ConvoyrRequest; next: NextHandler }] 18 | >; 19 | }; 20 | } 21 | 22 | /** 23 | * A plugin handle that just calls through the next plugin. 24 | */ 25 | export function createSpyPlugin({ 26 | shouldHandleRequest = ({ request }) => true, 27 | }: { 28 | shouldHandleRequest?: RequestCondition; 29 | } = {}): SpyPlugin { 30 | return { 31 | shouldHandleRequest: jest.fn(shouldHandleRequest), 32 | handler: { 33 | handle: jest.fn(({ request, next }) => next.handle({ request })), 34 | }, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /libs/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/core/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "declaration": true, 8 | "inlineSources": true, 9 | "types": ["node", "jest"], 10 | "lib": ["dom", "es2018"] 11 | }, 12 | "angularCompilerOptions": { 13 | "skipTemplateCodegen": true, 14 | "strictMetadataEmit": true, 15 | "fullTemplateTypeCheck": true, 16 | "strictInjectionParameters": true, 17 | "enableResourceInlining": true 18 | }, 19 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /libs/core/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } -------------------------------------------------------------------------------- /libs/core/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/core/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "convoyr", "camelCase"], 5 | "component-selector": [true, "element", "convoyr", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-auth/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 | # [4.0.0](https://github.com/jscutlery/convoyr/compare/v3.2.0...v4.0.0) (2020-05-30) 7 | 8 | **Note:** Version bump only for package @convoyr/plugin-auth 9 | 10 | 11 | 12 | 13 | 14 | # [3.2.0](https://github.com/jscutlery/convoyr/compare/v3.1.0...v3.2.0) (2020-05-23) 15 | 16 | **Note:** Version bump only for package @convoyr/plugin-auth 17 | 18 | 19 | 20 | 21 | 22 | # [3.1.0](https://github.com/jscutlery/convoyr/compare/v3.0.0...v3.1.0) (2020-05-20) 23 | 24 | **Note:** Version bump only for package @convoyr/plugin-auth 25 | 26 | 27 | 28 | 29 | 30 | # [3.0.0](https://github.com/jscutlery/convoyr/compare/v2.2.0...v3.0.0) (2020-04-24) 31 | 32 | 33 | ### Features 34 | 35 | * ✅ `NextFn` to `NextHandler` object ([530cb97](https://github.com/jscutlery/convoyr/commit/530cb97dab4404bfc9e2ad5b035a855a73b95a39)) 36 | 37 | 38 | ### BREAKING CHANGES 39 | 40 | * The `NextFn` type used for calling the next plugin and 41 | the final HTTP handler is removed in favor of an object following the `NextHandler` interface. 42 | 43 | 44 | 45 | 46 | 47 | # [2.2.0](https://github.com/jscutlery/convoyr/compare/v2.1.1...v2.2.0) (2020-04-23) 48 | 49 | **Note:** Version bump only for package @convoyr/plugin-auth 50 | 51 | ## [2.1.1](https://github.com/jscutlery/convoyr/compare/v2.1.0...v2.1.1) (2020-04-16) 52 | 53 | **Note:** Version bump only for package @convoyr/plugin-auth 54 | 55 | # [2.1.0](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.1.0) (2020-04-11) 56 | 57 | ### Bug Fixes 58 | 59 | - **plugin-auth:** 🐞 don't send token if null or undefined ([b9eacd5](https://github.com/jscutlery/convoyr/commit/b9eacd585cb06b36d8e9e21cdf79a07c16a9258d)) 60 | - **plugin-auth:** 🐞 fix token nullish stream value ([afb73d7](https://github.com/jscutlery/convoyr/commit/afb73d70fcf38fd3bdcc4bf4aae8e036e8c7fb57)) 61 | - **plugin-auth:** 🐞 forward error instead of silently fail ([135db10](https://github.com/jscutlery/convoyr/commit/135db100e52e451dc7fe4e216af1fb14af9034ea)) 62 | 63 | ### Features 64 | 65 | - **plugin-auth:** ✅ make sure we are always using the latest token ([e1e313d](https://github.com/jscutlery/convoyr/commit/e1e313d1e1a1361cbcf78fac08c99dc7eaa42705)) 66 | 67 | ## [2.0.2](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.0.2) (2020-04-08) 68 | 69 | ### Bug Fixes 70 | 71 | - **plugin-auth:** 🐞 forward error instead of silently fail ([135db10](https://github.com/jscutlery/convoyr/commit/135db100e52e451dc7fe4e216af1fb14af9034ea)) 72 | 73 | ## [2.0.1](https://github.com/jscutlery/convoyr/compare/v2.0.0...v2.0.1) (2020-04-07) 74 | 75 | ### Bug Fixes 76 | 77 | - **plugin-auth:** 🐞 fix `onUnauthorized` function call ([fc4b030](https://github.com/jscutlery/convoyr/commit/fc4b030c1872bc6b3f4fd5ced3748099aa2e7f9e)) 78 | 79 | # [2.0.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v2.0.0) (2020-04-01) 80 | 81 | ### Features 82 | 83 | - ✅ rename `condition` to `shouldHandleRequest` ([9e93b5d](https://github.com/jscutlery/convoyr/commit/9e93b5d20e4c3cb0ef94b5b6a1440565b685b6c7)) 84 | 85 | ### BREAKING CHANGES 86 | 87 | - rename `condition` to `shouldHandleRequest` 88 | 89 | Co-authored-by: Edouard Bozon 90 | 91 | # [1.2.0](https://github.com/jscutlery/convoyr/compare/v1.1.0...v1.2.0) (2020-03-31) 92 | 93 | **Note:** Version bump only for package @convoyr/plugin-auth 94 | 95 | # [1.1.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v1.1.0) (2020-01-14) 96 | 97 | **Note:** Version bump only for package @convoyr/plugin-auth 98 | -------------------------------------------------------------------------------- /libs/plugin-auth/README.md: -------------------------------------------------------------------------------- 1 | # @convoyr/plugin-auth 2 | 3 | > A auth plugin for [Convoyr](https://github.com/jscutlery/convoyr). 4 | 5 | This plugin takes care of handling authorization by: 6 | 7 | - adding the `Authorization` header with the given token automatically for each request matching a custom condition, 8 | - triggering a custom token expiration logic on `401 Unauthorized` http responses. 9 | 10 | This plugins helps avoiding all the http interceptor boilerplate required to add the authorization token and detect token expiration. 11 | 12 | Using matchers like `matchOrigin`, we'll ensure that the token is sent to the right API. 13 | This also helps using different tokens for different APIs in the same app. 14 | 15 | ## Requirements 16 | 17 | The plugin requires `@convoyr/core` and `@convoyr/angular` to be installed. 18 | 19 | ## Installation 20 | 21 | ```bash 22 | yarn add @convoyr/plugin-cache @convoyr/core 23 | ``` 24 | 25 | or 26 | 27 | ```bash 28 | npm install @convoyr/plugin-cache @convoyr/core 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```ts 34 | import { ConvoyrModule } from '@convoyr/angular'; 35 | import { createAuthPlugin } from '@convoyr/plugin-auth'; 36 | 37 | @NgModule({ 38 | declarations: [AppComponent], 39 | imports: [ 40 | BrowserModule, 41 | HttpClientModule, 42 | ConvoyrModule.forRoot({ 43 | deps: [AuthService], 44 | config: (authService: AuthService) => ({ 45 | plugins: [ 46 | createAuthPlugin({ 47 | shouldHandleRequest: matchOrigin('https://secure-origin.com'), 48 | token: authService.getToken(), // Returns an Observable. 49 | onUnauthorized: () => authService.markTokenExpired(), 50 | }), 51 | ], 52 | }), 53 | }), 54 | ], 55 | bootstrap: [AppComponent], 56 | }) 57 | export class AppModule {} 58 | ``` 59 | 60 | ### Available options 61 | 62 | You can give a partial configuration object it will be merged with default values. 63 | 64 | | Property | Type | Required | Default value | Description | 65 | | --------------------- | -------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------- | 66 | | `token` | `Observable` | Yes | `undefined` | The bearer token that will be added to every matching request in the `Authorization` header. | 67 | | `onUnauthorized` | `OnUnauthorized` | No | `undefined` | A function executed when an unauthorized response is thrown. | 68 | | `shouldHandleRequest` | `RequestCondition` | No | `undefined` | Predicate function to know which request the plugin should handle. | 69 | 70 | To know more about the `shouldHandleRequest` property check-out the [conditional handling section](https://github.com/jscutlery/convoyr/blob/master/docs/custom-plugin.md#conditional-handling). 71 | -------------------------------------------------------------------------------- /libs/plugin-auth/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'plugin-auth', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/plugin-auth', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'] 11 | }; 12 | -------------------------------------------------------------------------------- /libs/plugin-auth/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "dist", 4 | "lib": { 5 | "entryFile": "src/index.ts", 6 | "umdModuleIds": { 7 | "@convoyr/core": "@convoyr/core" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /libs/plugin-auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convoyr/plugin-auth", 3 | "version": "4.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "repository": "git@github.com:jscutlery/convoyr.git", 7 | "scripts": { 8 | "prepublishOnly": "ng build plugin-auth --prod" 9 | }, 10 | "dependencies": { 11 | "tslib": "^2.0.0" 12 | }, 13 | "peerDependencies": { 14 | "@convoyr/core": ">= 3.0.0", 15 | "rxjs": "^6.5.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createAuthPlugin } from './lib/create-auth-plugin'; 2 | 3 | describe('Public API', () => { 4 | it('Should expose Auth plugin API', () => { 5 | expect(createAuthPlugin).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/index.ts: -------------------------------------------------------------------------------- 1 | export { createAuthPlugin, AuthPluginOptions } from './lib/create-auth-plugin'; 2 | export { OnUnauthorized } from './lib/on-unauthorized'; 3 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/lib/auth-handler.ts: -------------------------------------------------------------------------------- 1 | import { PluginHandler, PluginHandlerArgs } from '@convoyr/core'; 2 | import { defer, Observable, of, throwError } from 'rxjs'; 3 | import { catchError, map, switchMap, withLatestFrom } from 'rxjs/operators'; 4 | 5 | import { OnUnauthorized } from './on-unauthorized'; 6 | import { setHeader } from './set-header'; 7 | 8 | export interface HandlerOptions { 9 | token: Observable; 10 | onUnauthorized?: OnUnauthorized; 11 | } 12 | 13 | export class AuthHandler implements PluginHandler { 14 | private _token$: Observable; 15 | private _onUnauthorized: OnUnauthorized; 16 | 17 | constructor({ token, onUnauthorized }: HandlerOptions) { 18 | this._token$ = token; 19 | this._onUnauthorized = onUnauthorized; 20 | } 21 | 22 | handle({ request: originalRequest, next }: PluginHandlerArgs) { 23 | return defer(() => { 24 | return of(originalRequest).pipe( 25 | withLatestFrom(this._token$), 26 | map(([request, token]) => { 27 | /* Don't add header if token is null or undefined. */ 28 | if (token == null) { 29 | return request; 30 | } 31 | 32 | return setHeader({ 33 | request, 34 | key: 'Authorization', 35 | value: `Bearer ${token}`, 36 | }); 37 | }), 38 | switchMap((request) => next.handle({ request })), 39 | catchError((response) => { 40 | if (response.status === 401) { 41 | /* tslint:disable-next-line: no-unused-expression */ 42 | this._onUnauthorized && this._onUnauthorized(response); 43 | } 44 | 45 | return throwError(response); 46 | }) 47 | ); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/lib/create-auth-plugin-params.spec.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, of } from 'rxjs'; 2 | import { matchOrigin } from '@convoyr/core'; 3 | 4 | import { AuthHandler } from './auth-handler'; 5 | import { createAuthPlugin } from './create-auth-plugin'; 6 | import { OnUnauthorized } from './on-unauthorized'; 7 | 8 | jest.mock('./auth-handler'); 9 | 10 | describe('AuthPlugin', () => { 11 | const mockAuthHandler = AuthHandler as jest.Mock; 12 | 13 | it('should create the auth handler with default options', () => { 14 | const token = of('TOKEN'); 15 | const onUnauthorized: OnUnauthorized = () => true; 16 | const matchSomewhereOrigin = matchOrigin('https://somewhere.com'); 17 | 18 | const plugin = createAuthPlugin({ 19 | token, 20 | onUnauthorized, 21 | shouldHandleRequest: matchSomewhereOrigin, 22 | }); 23 | 24 | mockAuthHandler.mockReturnValue(EMPTY); 25 | 26 | expect(plugin.shouldHandleRequest).toBe(matchSomewhereOrigin); 27 | expect(AuthHandler).toHaveBeenCalledWith({ 28 | token, 29 | onUnauthorized, 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/lib/create-auth-plugin.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '@convoyr/core'; 2 | 3 | import { AuthHandler, HandlerOptions } from './auth-handler'; 4 | 5 | export interface AuthPluginOptions extends HandlerOptions { 6 | shouldHandleRequest?: RequestCondition; 7 | } 8 | 9 | export function createAuthPlugin({ 10 | shouldHandleRequest, 11 | token, 12 | onUnauthorized, 13 | }: AuthPluginOptions) { 14 | return { 15 | shouldHandleRequest, 16 | handler: new AuthHandler({ 17 | token, 18 | onUnauthorized, 19 | }), 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/lib/on-unauthorized.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrResponse } from '@convoyr/core'; 2 | 3 | export type OnUnauthorized = (response: ConvoyrResponse) => void; 4 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/lib/set-header.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrRequest } from '@convoyr/core'; 2 | 3 | export function setHeader({ 4 | request, 5 | key, 6 | value, 7 | }: { 8 | request: ConvoyrRequest; 9 | key: string; 10 | value: string; 11 | }): ConvoyrRequest { 12 | return { 13 | ...request, 14 | headers: { 15 | ...request.headers, 16 | [key]: value, 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /libs/plugin-auth/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/plugin-auth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-auth/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "declaration": true, 8 | "inlineSources": true, 9 | "types": [], 10 | "lib": ["dom", "es2018"], 11 | "paths": { 12 | "@convoyr/core": ["libs/core/dist"] 13 | } 14 | }, 15 | "angularCompilerOptions": { 16 | "annotateForClosureCompiler": true, 17 | "skipTemplateCodegen": true, 18 | "strictMetadataEmit": true, 19 | "fullTemplateTypeCheck": true, 20 | "strictInjectionParameters": true, 21 | "enableResourceInlining": true 22 | }, 23 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /libs/plugin-auth/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } -------------------------------------------------------------------------------- /libs/plugin-auth/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/plugin-auth/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "convoyr", "camelCase"], 5 | "component-selector": [true, "element", "convoyr", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-cache/README.md: -------------------------------------------------------------------------------- 1 | # @convoyr/plugin-cache 2 | 3 | > A cache plugin for [Convoyr](https://github.com/jscutlery/convoyr). 4 | 5 | This plugin cache network requests using the _cache-then-network_ strategy. First the plugin returns the data from cache, then sends the request, and finally comes with fresh data again. This technique drastically improve UI reactivity. 6 | 7 | ## Requirements 8 | 9 | The plugin requires `@convoyr/core` and `@convoyr/angular` to be installed. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | yarn add @convoyr/plugin-cache 15 | ``` 16 | 17 | or 18 | 19 | ```bash 20 | npm install @convoyr/plugin-cache 21 | ``` 22 | 23 | ## Usage 24 | 25 | The whole configuration object is optional. 26 | 27 | ```ts 28 | import { ConvoyrModule } from '@convoyr/angular'; 29 | import { createCachePlugin } from '@convoyr/plugin-cache'; 30 | 31 | @NgModule({ 32 | declarations: [AppComponent], 33 | imports: [ 34 | BrowserModule, 35 | HttpClientModule, 36 | ConvoyrModule.forRoot({ 37 | plugins: [createCachePlugin()], 38 | }), 39 | ], 40 | bootstrap: [AppComponent], 41 | }) 42 | export class AppModule {} 43 | ``` 44 | 45 | ### Available options 46 | 47 | You can give a partial configuration object it will be merged with default values. 48 | 49 | | Property | Type | Default value | Description | 50 | | --------------------- | ------------------ | -------------------------------- | ------------------------------------------------------------------ | 51 | | `addCacheMetadata` | `boolean` | `false` | Add cache metadata to the response body. | 52 | | `storage` | `Storage` | `new MemoryStorage()` | Storage used to store the cache. | 53 | | `shouldHandleRequest` | `RequestCondition` | `isGetMethodAndJsonResponseType` | Predicate function to know which request the plugin should handle. | 54 | 55 | Here is an example passing a configuration object. 56 | 57 | ```ts 58 | import { createCachePlugin, MemoryStorage } from '@convoyr/plugin-cache'; 59 | 60 | @NgModule({ 61 | imports: [ 62 | ConvoyrModule.forRoot({ 63 | plugins: [ 64 | createCachePlugin({ 65 | addCacheMetadata: true, 66 | storage: new MemoryStorage(), 67 | }), 68 | ], 69 | }), 70 | ], 71 | }) 72 | export class AppModule {} 73 | ``` 74 | 75 | To know more about the `shouldHandleRequest` property check-out the [conditional handling section](https://github.com/jscutlery/convoyr/blob/master/docs/custom-plugin.md#conditional-handling). 76 | 77 | ### Metadata 78 | 79 | You can add cache metadata to the response body and use it in the application. For example you can display something that showup that data are from cache. 80 | 81 | Be careful because this option changes the body's shape and breaks existing code that need to access to the response body. 82 | 83 | Here is an example showing a response body with `addCacheMetadata` set to `false` (default). 84 | 85 | ```json 86 | { "answer": 42 } 87 | ``` 88 | 89 | The same response body with `addCacheMetadata` set to `true`. 90 | 91 | ```json 92 | { 93 | "data": { "answer": 42 }, 94 | "cacheMetadata": { 95 | "createdAt": "2019-11-24T00:00:00.000Z", 96 | "isFromCache": true 97 | } 98 | } 99 | ``` 100 | 101 | Data are moved in a dedicated object and cache metadata are added. 102 | 103 | ### `MemoryStorage` 104 | 105 | #### `MemoryStorage` options 106 | 107 | | Property | Type | Default value | 108 | | --------- | ----------------- | ------------- | 109 | | `maxSize` | `number | string` | `100` | 110 | 111 | #### `MemoryStorage` max size 112 | 113 | Default's storage size of the `MemoryStorage` is 100 requests. Above this limit, the least recently used response will be removed to free some space. 114 | 115 | `MemoryStorage` max size can be configured when initializing the storage and the cache plugin. 116 | 117 | ```ts 118 | ConvoyrModule.forRoot({ 119 | plugins: [ 120 | createCachePlugin({ 121 | storage: new MemoryStorage({ maxSize: 2000 }), 122 | }), 123 | ], 124 | }); 125 | ``` 126 | 127 | The `maxSize` can also be configured using human readable bytes format if a `string` is passed, for example: 128 | 129 | ```ts 130 | ConvoyrModule.forRoot({ 131 | plugins: [ 132 | createCachePlugin({ 133 | storage: new MemoryStorage({ maxSize: '2000 b' }), 134 | }), 135 | ], 136 | }); 137 | ``` 138 | 139 | Supported units and abbreviations are as follows and are case-insensitive: 140 | 141 | - `b` for bytes 142 | - `kb` for kilobytes 143 | - `mb` for megabytes 144 | - `gb` for gigabytes 145 | - `tb` for terabytes 146 | - `pb` for petabytes 147 | 148 | ### Custom storage 149 | 150 | You can use any other kind of storage (e.g. Redis) by implementing the `Storage` interface. 151 | -------------------------------------------------------------------------------- /libs/plugin-cache/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'plugin-cache', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/plugin-cache', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'], 11 | setupFiles: ['jest-date-mock'] 12 | }; 13 | -------------------------------------------------------------------------------- /libs/plugin-cache/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "dist", 4 | "lib": { 5 | "entryFile": "src/index.ts", 6 | "umdModuleIds": { 7 | "@convoyr/core": "@convoyr/core", 8 | "lru-cache": "LRU", 9 | "buffer": "buffer", 10 | "bytes": "bytes" 11 | } 12 | }, 13 | "whitelistedNonPeerDependencies": ["lru-cache", "buffer", "bytes"] 14 | } 15 | -------------------------------------------------------------------------------- /libs/plugin-cache/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convoyr/plugin-cache", 3 | "version": "4.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "repository": "git@github.com:jscutlery/convoyr.git", 7 | "scripts": { 8 | "prepublishOnly": "ng build plugin-cache --prod" 9 | }, 10 | "peerDependencies": { 11 | "@convoyr/core": ">= 3.0.0", 12 | "rxjs": "^6.5.4" 13 | }, 14 | "dependencies": { 15 | "buffer": "^6.0.0", 16 | "bytes": "^3.1.0", 17 | "lru-cache": "^6.0.0", 18 | "tslib": "^2.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as publicApi from './index'; 2 | 3 | describe('Public API', () => { 4 | it('should expose the cache plugin', () => { 5 | expect(publicApi.createCachePlugin).toBeDefined(); 6 | expect(publicApi.MemoryStorage).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createCachePlugin, 3 | CachePluginOptions, 4 | } from './lib/create-cache-plugin'; 5 | export { MemoryStorage } from './lib/storages/memory-storage'; 6 | export { Storage } from './lib/storages/storage'; 7 | export { WithCacheMetadata, ConvoyCacheResponse } from './lib/cache-response'; 8 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/cache-entry.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResponse } from '@convoyr/core'; 2 | import { advanceTo, clear } from 'jest-date-mock'; 3 | 4 | import { createCacheEntry } from './cache-entry'; 5 | 6 | describe('createCacheEntry', () => { 7 | beforeEach(() => advanceTo(new Date('2020-01-13T00:00:00.000Z'))); 8 | 9 | afterEach(() => clear()); 10 | 11 | it('should create a dated cache entry', () => { 12 | const response = createResponse({ status: 200, statusText: 'Ok' }); 13 | const cacheEntryArgs = { createdAt: new Date().toString(), response }; 14 | 15 | expect(createCacheEntry(cacheEntryArgs)).toEqual({ 16 | createdAt: new Date('2020-01-13T00:00:00.000Z'), 17 | response, 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/cache-entry.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrResponse } from '@convoyr/core'; 2 | 3 | export interface CacheEntry { 4 | createdAt: Date; 5 | response: ConvoyrResponse; 6 | } 7 | 8 | export interface CacheEntryArgs { 9 | createdAt: string | Date; 10 | response: ConvoyrResponse; 11 | } 12 | 13 | export function createCacheEntry(cacheEntry: CacheEntryArgs): CacheEntry { 14 | return { 15 | createdAt: new Date(cacheEntry.createdAt), 16 | response: cacheEntry.response, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/cache-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createResponse, 3 | ConvoyrRequest, 4 | ConvoyrResponse, 5 | PluginHandler, 6 | PluginHandlerArgs, 7 | } from '@convoyr/core'; 8 | import { defer, EMPTY, merge, Observable, of } from 'rxjs'; 9 | import { 10 | map, 11 | mergeMap, 12 | shareReplay, 13 | switchMapTo, 14 | takeUntil, 15 | } from 'rxjs/operators'; 16 | 17 | import { CacheEntry, createCacheEntry } from './cache-entry'; 18 | import { 19 | CacheMetadata, 20 | createCacheMetadata, 21 | createEmptyCacheMetadata, 22 | } from './cache-metadata'; 23 | import { ConvoyCacheResponse, WithCacheMetadata } from './cache-response'; 24 | import { Storage } from './storages/storage'; 25 | 26 | export interface HandlerOptions { 27 | addCacheMetadata: boolean; 28 | storage: Storage; 29 | } 30 | 31 | export type CacheHandlerResponse = ConvoyrResponse | ConvoyCacheResponse; 32 | 33 | export class CacheHandler implements PluginHandler { 34 | private _shouldAddCacheMetadata: boolean; 35 | private _storage: Storage; 36 | 37 | constructor({ addCacheMetadata, storage }: HandlerOptions) { 38 | this._shouldAddCacheMetadata = addCacheMetadata; 39 | this._storage = storage; 40 | } 41 | 42 | handle({ 43 | request, 44 | next, 45 | }: PluginHandlerArgs): Observable { 46 | const shouldAddCacheMetadata = this._shouldAddCacheMetadata; 47 | 48 | const fromNetwork$: Observable = next 49 | .handle({ 50 | request, 51 | }) 52 | .pipe( 53 | mergeMap((response) => { 54 | /* Return response immediately but store in cache as side effect. */ 55 | return merge( 56 | of(response), 57 | this._store(request, response).pipe(switchMapTo(EMPTY)) 58 | ); 59 | }), 60 | shareReplay({ 61 | refCount: true, 62 | bufferSize: 1, 63 | }) 64 | ); 65 | 66 | const fromCache$: Observable = defer(() => 67 | this._load(request) 68 | ).pipe( 69 | map((cacheEntry) => 70 | this._createResponseWithOptionalMetadata({ 71 | response: cacheEntry.response, 72 | shouldAddCacheMetadata, 73 | cacheMetadata: createCacheMetadata(cacheEntry), 74 | }) 75 | ), 76 | takeUntil(fromNetwork$) 77 | ); 78 | 79 | /* Order is important here because if we subscribe to fromCache$ first, it will subscribe to fromNetwork$ 80 | * and `takeUntil` will immediately unsubscribe from it because the result is synchronous. 81 | * If fromNetwork$ is first, it will subscribe and the subscription will be shared with the `takeUntil` 82 | * thanks to shareReplay. */ 83 | return merge( 84 | fromNetwork$.pipe( 85 | map((response) => 86 | this._createResponseWithOptionalMetadata({ 87 | response, 88 | shouldAddCacheMetadata, 89 | cacheMetadata: createEmptyCacheMetadata(), 90 | }) 91 | ) 92 | ), 93 | fromCache$ 94 | ); 95 | } 96 | 97 | /* Store metadata belong cache. */ 98 | private _store( 99 | request: ConvoyrRequest, 100 | response: ConvoyrResponse 101 | ): Observable { 102 | return defer(() => { 103 | const key = this._serializeCacheKey(request); 104 | const cacheEntry = createCacheEntry({ 105 | createdAt: new Date(), 106 | response, 107 | }); 108 | const cache = JSON.stringify(cacheEntry); 109 | 110 | return this._storage.set(key, cache); 111 | }); 112 | } 113 | 114 | private _load(request: ConvoyrRequest): Observable { 115 | return this._storage.get(this._serializeCacheKey(request)).pipe( 116 | mergeMap((rawCacheEntry) => { 117 | /* There's no entry. */ 118 | if (rawCacheEntry == null) { 119 | return EMPTY; 120 | } 121 | 122 | /* Parse the cache entry. */ 123 | const cacheEntry = createCacheEntry(JSON.parse(rawCacheEntry)); 124 | 125 | return of(cacheEntry); 126 | }) 127 | ); 128 | } 129 | 130 | /* Create a unique key by request URI to retrieve cache later. */ 131 | private _serializeCacheKey(request: ConvoyrRequest): string { 132 | const { params } = request; 133 | const hasParams = Object.keys(params).length > 0; 134 | 135 | return JSON.stringify({ 136 | u: request.url, 137 | p: hasParams ? request.params : undefined, 138 | }); 139 | } 140 | 141 | private _createResponseWithOptionalMetadata({ 142 | response, 143 | cacheMetadata, 144 | shouldAddCacheMetadata, 145 | }: { 146 | response: ConvoyrResponse; 147 | cacheMetadata: CacheMetadata; 148 | shouldAddCacheMetadata: boolean; 149 | }): ConvoyrResponse | ConvoyCacheResponse { 150 | const body = shouldAddCacheMetadata 151 | ? ({ 152 | cacheMetadata, 153 | data: response.body, 154 | } as WithCacheMetadata) 155 | : response.body; 156 | return createResponse({ 157 | ...response, 158 | body, 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/cache-metadata.ts: -------------------------------------------------------------------------------- 1 | export interface CacheMetadataBase { 2 | createdAt?: Date; 3 | } 4 | 5 | /* Adds computed fields like isFromCache. */ 6 | export interface CacheMetadata extends CacheMetadataBase { 7 | isFromCache: boolean; 8 | } 9 | 10 | export function createCacheMetadata(args: CacheMetadataBase): CacheMetadata { 11 | return { 12 | createdAt: args.createdAt, 13 | isFromCache: true 14 | }; 15 | } 16 | 17 | export function createEmptyCacheMetadata(): CacheMetadata { 18 | return { 19 | isFromCache: false 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/cache-response.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrResponse } from '@convoyr/core'; 2 | import { CacheMetadata } from './cache-metadata'; 3 | 4 | export interface WithCacheMetadata { 5 | cacheMetadata: CacheMetadata; 6 | data: TData; 7 | } 8 | 9 | export type ConvoyCacheResponse = ConvoyrResponse< 10 | WithCacheMetadata 11 | >; 12 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/create-cache-plugin-params.ts: -------------------------------------------------------------------------------- 1 | import { and, matchMethod, matchResponseType } from '@convoyr/core'; 2 | import { EMPTY } from 'rxjs'; 3 | import { CacheHandler } from './cache-handler'; 4 | import { createCachePlugin } from './create-cache-plugin'; 5 | import { MemoryStorage } from './storages/memory-storage'; 6 | 7 | jest.mock('./cache-handler'); 8 | 9 | describe('CachePlugin', () => { 10 | const mockCacheHandler = CacheHandler as jest.Mock; 11 | 12 | it('should create the cache handler with default options', () => { 13 | const plugin = createCachePlugin(); 14 | 15 | mockCacheHandler.mockReturnValue(EMPTY); 16 | 17 | expect(plugin.shouldHandleRequest).toBe( 18 | and(matchMethod('GET'), matchResponseType('json')) 19 | ); 20 | expect(CacheHandler).toHaveBeenCalledWith({ 21 | addCacheMetadata: false, 22 | storage: new MemoryStorage({ maxSize: 100 }), 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/create-cache-plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RequestCondition, 3 | ConvoyrRequest, 4 | and, 5 | matchMethod, 6 | matchResponseType, 7 | } from '@convoyr/core'; 8 | 9 | import { CacheHandler, HandlerOptions } from './cache-handler'; 10 | import { MemoryStorage } from './storages/memory-storage'; 11 | 12 | export interface CachePluginOptions extends HandlerOptions { 13 | shouldHandleRequest: RequestCondition; 14 | } 15 | 16 | export function createCachePlugin({ 17 | addCacheMetadata = false, 18 | storage = new MemoryStorage({ maxSize: 100 }), 19 | shouldHandleRequest = and(matchMethod('GET'), matchResponseType('json')), 20 | }: Partial = {}) { 21 | return { 22 | shouldHandleRequest, 23 | handler: new CacheHandler({ addCacheMetadata, storage }), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/storages/memory-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { MemoryStorage } from './memory-storage'; 2 | 3 | describe('MemoryStorage', () => { 4 | let memoryStorage: MemoryStorage; 5 | 6 | async function get(key: string) { 7 | return await memoryStorage.get(key).toPromise(); 8 | } 9 | 10 | async function set(key: string, value: string) { 11 | return await memoryStorage.set(key, value).toPromise(); 12 | } 13 | 14 | function getPrivateLruCache() { 15 | return memoryStorage['_lruCache']; 16 | } 17 | 18 | describe('with default maxSize', () => { 19 | beforeEach(() => { 20 | memoryStorage = new MemoryStorage(); 21 | }); 22 | 23 | it('should pass the right configuration to LRU', () => { 24 | const lruCache = getPrivateLruCache(); 25 | expect(lruCache.max).toEqual(100); 26 | expect(lruCache.lengthCalculator('VALUE')).toEqual(1); 27 | }); 28 | }); 29 | 30 | describe('with maxSize of 1', () => { 31 | beforeEach(() => { 32 | memoryStorage = new MemoryStorage({ 33 | maxSize: 1 34 | }); 35 | }); 36 | 37 | it('should return value if maxSize is not reached', async () => { 38 | await set('Key A', 'Value A'); 39 | expect(await get('Key A')).toEqual('Value A'); 40 | }); 41 | 42 | it('should remove the oldest entry when maxSize is reached', async () => { 43 | await set('Key A', 'Value A'); 44 | 45 | /* Adding this entry should remove the previous entry. */ 46 | await set('Key B', 'Value B'); 47 | 48 | expect(await get('Key A')).toEqual(undefined); 49 | expect(await get('Key B')).toEqual('Value B'); 50 | }); 51 | }); 52 | 53 | describe('with maxSize of 2', () => { 54 | beforeEach(() => { 55 | memoryStorage = new MemoryStorage({ 56 | maxSize: 2 57 | }); 58 | }); 59 | 60 | it('should remove the least recently used entry when maxSize is reached', async () => { 61 | await set('Key A', 'Value A'); 62 | await set('Key B', 'Value B'); 63 | 64 | /* Even if A is the oldest, B is the least recently used as we just retrieved A. */ 65 | get('Key A'); 66 | 67 | await set('Key C', 'Value C'); 68 | 69 | expect(await get('Key A')).toBe('Value A'); 70 | expect(await get('Key B')).toBe(undefined); 71 | expect(await get('Key C')).toBe('Value C'); 72 | }); 73 | }); 74 | 75 | describe('with maxSize of human readable bytes', () => { 76 | beforeEach(() => { 77 | memoryStorage = new MemoryStorage({ 78 | maxSize: '10 Bytes' 79 | }); 80 | }); 81 | 82 | it('should pass the right configuration to LRU', () => { 83 | const lruCache = getPrivateLruCache(); 84 | expect(lruCache.max).toEqual(10); 85 | expect(lruCache.lengthCalculator('VALUE')).toEqual(5); 86 | }); 87 | 88 | it('should remove least recently used', async () => { 89 | const cache = 'cache'; /* This is 5 bytes length */ 90 | 91 | await set('Key A', cache); 92 | await set('Key B', cache); 93 | await set('Key C', cache); 94 | 95 | expect(await get('Key A')).toBe(undefined); 96 | expect(await get('Key B')).toBe('cache'); 97 | expect(await get('Key C')).toBe('cache'); 98 | }); 99 | }); 100 | 101 | describe('with maxAge', () => { 102 | it.todo('🚧 should remove old entry when outdated'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/storages/memory-storage.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | import bytes from 'bytes'; 3 | import LRU from 'lru-cache'; 4 | import { defer, EMPTY, Observable, of } from 'rxjs'; 5 | 6 | import { Storage } from './storage'; 7 | 8 | export interface StorageArgs { 9 | maxSize?: number | string; 10 | } 11 | 12 | export class MemoryStorage implements Storage { 13 | private _lruCache: LRU; 14 | 15 | constructor({ maxSize = 100 }: StorageArgs = {}) { 16 | this._lruCache = this._createLru({ maxSize }); 17 | } 18 | 19 | get(key: string): Observable { 20 | return defer(() => { 21 | return of(this._lruCache.get(key)); 22 | }); 23 | } 24 | 25 | set(key: string, value: string): Observable { 26 | return defer(() => { 27 | this._lruCache.set(key, value); 28 | return EMPTY; 29 | }); 30 | } 31 | 32 | delete(key: string): Observable { 33 | return defer(() => { 34 | this._lruCache.del(key); 35 | return EMPTY; 36 | }); 37 | } 38 | 39 | private _createLru({ maxSize }: { maxSize: number | string }) { 40 | return new LRU(this._createLruOptions({ maxSize })); 41 | } 42 | 43 | private _createLruOptions(options: StorageArgs): LRU.Options { 44 | const { maxSize } = options; 45 | 46 | /* Handle human readable format */ 47 | if (typeof maxSize === 'string') { 48 | return { 49 | max: bytes(maxSize), 50 | 51 | /* Length is based on the size in bytes */ 52 | length(value) { 53 | return Buffer.from(value).length; 54 | } 55 | }; 56 | } 57 | 58 | /* Otherwise it's a "count like" max size */ 59 | return { 60 | max: maxSize 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/lib/storages/storage.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface Storage { 4 | get(key: string): Observable; 5 | set(key: string, value: string): Observable; 6 | delete(key: string): Observable; 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-cache/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/plugin-cache/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"], 5 | "esModuleInterop": true 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /libs/plugin-cache/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "declaration": true, 8 | "inlineSources": true, 9 | "types": [], 10 | "lib": ["dom", "es2018"], 11 | "paths": { 12 | "@convoyr/core": ["libs/core/dist"] 13 | } 14 | }, 15 | "angularCompilerOptions": { 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /libs/plugin-cache/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } -------------------------------------------------------------------------------- /libs/plugin-cache/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/plugin-cache/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "convoyr", "camelCase"], 5 | "component-selector": [true, "element", "convoyr", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-retry/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 | # [4.0.0](https://github.com/jscutlery/convoyr/compare/v3.2.0...v4.0.0) (2020-05-30) 7 | 8 | **Note:** Version bump only for package @convoyr/plugin-retry 9 | 10 | 11 | 12 | 13 | 14 | # [3.2.0](https://github.com/jscutlery/convoyr/compare/v3.1.0...v3.2.0) (2020-05-23) 15 | 16 | **Note:** Version bump only for package @convoyr/plugin-retry 17 | 18 | 19 | 20 | 21 | 22 | # [3.1.0](https://github.com/jscutlery/convoyr/compare/v3.0.0...v3.1.0) (2020-05-20) 23 | 24 | **Note:** Version bump only for package @convoyr/plugin-retry 25 | 26 | 27 | 28 | 29 | 30 | # [3.0.0](https://github.com/jscutlery/convoyr/compare/v2.2.0...v3.0.0) (2020-04-24) 31 | 32 | 33 | ### Features 34 | 35 | * ✅ `NextFn` to `NextHandler` object ([530cb97](https://github.com/jscutlery/convoyr/commit/530cb97dab4404bfc9e2ad5b035a855a73b95a39)) 36 | 37 | 38 | ### BREAKING CHANGES 39 | 40 | * The `NextFn` type used for calling the next plugin and 41 | the final HTTP handler is removed in favor of an object following the `NextHandler` interface. 42 | 43 | 44 | 45 | 46 | 47 | # [2.2.0](https://github.com/jscutlery/convoyr/compare/v2.1.1...v2.2.0) (2020-04-23) 48 | 49 | **Note:** Version bump only for package @convoyr/plugin-retry 50 | 51 | ## [2.1.1](https://github.com/jscutlery/convoyr/compare/v2.1.0...v2.1.1) (2020-04-16) 52 | 53 | **Note:** Version bump only for package @convoyr/plugin-retry 54 | 55 | # [2.1.0](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.1.0) (2020-04-11) 56 | 57 | **Note:** Version bump only for package @convoyr/plugin-retry 58 | 59 | ## [2.0.2](https://github.com/jscutlery/convoyr/compare/v2.0.1...v2.0.2) (2020-04-08) 60 | 61 | **Note:** Version bump only for package @convoyr/plugin-retry 62 | 63 | ## [2.0.1](https://github.com/jscutlery/convoyr/compare/v2.0.0...v2.0.1) (2020-04-07) 64 | 65 | **Note:** Version bump only for package @convoyr/plugin-retry 66 | 67 | # [2.0.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v2.0.0) (2020-04-01) 68 | 69 | ### Features 70 | 71 | - ✅ rename `condition` to `shouldHandleRequest` ([9e93b5d](https://github.com/jscutlery/convoyr/commit/9e93b5d20e4c3cb0ef94b5b6a1440565b685b6c7)) 72 | 73 | ### BREAKING CHANGES 74 | 75 | - rename `condition` to `shouldHandleRequest` 76 | 77 | Co-authored-by: Edouard Bozon 78 | 79 | # [1.2.0](https://github.com/jscutlery/convoyr/compare/v1.1.0...v1.2.0) (2020-03-31) 80 | 81 | **Note:** Version bump only for package @convoyr/plugin-retry 82 | 83 | # [1.1.0](https://github.com/jscutlery/convoyr/compare/v1.0.0...v1.1.0) (2020-01-14) 84 | 85 | **Note:** Version bump only for package @convoyr/plugin-retry 86 | -------------------------------------------------------------------------------- /libs/plugin-retry/README.md: -------------------------------------------------------------------------------- 1 | # @convoyr/plugin-retry 2 | 3 | > A retry plugin for [Convoyr](https://github.com/jscutlery/convoyr). 4 | 5 | This plugin retries failed network requests using a configurable back-off strategy. 6 | 7 | ## Requirements 8 | 9 | The plugin requires `@convoyr/core` and `@convoyr/angular` to be installed. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | yarn add @convoyr/plugin-retry 15 | ``` 16 | 17 | or 18 | 19 | ```bash 20 | npm install @convoyr/plugin-retry 21 | ``` 22 | 23 | ## Usage 24 | 25 | The whole configuration object is optional. 26 | 27 | ```ts 28 | import { ConvoyrModule } from '@convoyr/angular'; 29 | import { createRetryPlugin } from '@convoyr/plugin-retry'; 30 | 31 | @NgModule({ 32 | declarations: [AppComponent], 33 | imports: [ 34 | BrowserModule, 35 | HttpClientModule, 36 | ConvoyrModule.forRoot({ 37 | plugins: [createRetryPlugin()], 38 | }), 39 | ], 40 | bootstrap: [AppComponent], 41 | }) 42 | export class AppModule {} 43 | ``` 44 | 45 | ### Available options 46 | 47 | You can give a partial configuration object it will be merged with default values. 48 | 49 | | Property | Type | Default value | Description | 50 | | --------------------- | ------------------ | ------------------------ | ------------------------------------------------------------------ | 51 | | `initialIntervalMs` | `number` | `300` | Duration before the first retry. | 52 | | `maxIntervalMs` | `number` | `10_000` | Maximum time span before retrying. | 53 | | `maxRetries` | `number` | `3` | Maximum number of retries. | 54 | | `shouldRetry` | `RetryPredicate` | `isServerOrUnknownError` | Predicate function to know which failed request should be retried. | 55 | | `shouldHandleRequest` | `RequestCondition` | `() => true` | Predicate function to know which request the plugin should handle. | 56 | 57 | Here is an example passing a configuration object. 58 | 59 | Keep in mind that HTTP error is not emitted while the plugin is retrying. In the following example the HTTP error will be emitted after 10 retries, then the observable completes. 60 | 61 | ```ts 62 | import { MemoryStorage } from '@convoyr/plugin-cache'; 63 | 64 | @NgModule({ 65 | imports: [ 66 | ConvoyrModule.forRoot({ 67 | plugins: [ 68 | createRetryPlugin({ 69 | initialIntervalMs: 500, 70 | maxIntervalMs: 20_000, 71 | maxRetries: 10, 72 | shouldRetry: (response) => response.status !== 404, 73 | shouldHandleRequest: ({ request }) => 74 | request.url.includes('api.github.com'), 75 | }), 76 | ], 77 | }), 78 | ], 79 | }) 80 | export class AppModule {} 81 | ``` 82 | -------------------------------------------------------------------------------- /libs/plugin-retry/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'plugin-retry', 3 | preset: '../../jest.config.js', 4 | coverageDirectory: '../../coverage/libs/plugin-retry', 5 | snapshotSerializers: [ 6 | 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 7 | 'jest-preset-angular/build/AngularSnapshotSerializer.js', 8 | 'jest-preset-angular/build/HTMLCommentSerializer.js' 9 | ], 10 | coverageReporters: ['html', 'lcov'] 11 | }; 12 | -------------------------------------------------------------------------------- /libs/plugin-retry/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "dist", 4 | "lib": { 5 | "entryFile": "src/index.ts", 6 | "umdModuleIds": { 7 | "@convoyr/core": "@convoyr/core", 8 | "backoff-rxjs": "backoff-rxjs" 9 | } 10 | }, 11 | "whitelistedNonPeerDependencies": ["backoff-rxjs"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/plugin-retry/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@convoyr/plugin-retry", 3 | "version": "4.0.0", 4 | "license": "MIT", 5 | "private": false, 6 | "repository": "git@github.com:jscutlery/convoyr.git", 7 | "scripts": { 8 | "prepublishOnly": "ng build plugin-retry --prod" 9 | }, 10 | "peerDependencies": { 11 | "@convoyr/core": ">= 3.0.0", 12 | "rxjs": "^6.5.4" 13 | }, 14 | "dependencies": { 15 | "backoff-rxjs": "^6.3.3", 16 | "tslib": "^2.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as publicApi from './index'; 2 | 3 | describe('Public API', () => { 4 | it('should expose the retry plugin', () => { 5 | expect(publicApi.createRetryPlugin).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | RetryPluginOptions, 3 | createRetryPlugin 4 | } from './lib/create-retry-plugin'; 5 | export { RetryPredicate } from './lib/predicates/retry-predicate'; 6 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/create-retry-plugin-params.spec.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY } from 'rxjs'; 2 | 3 | import { createRetryPlugin } from './create-retry-plugin'; 4 | import { isServerOrUnknownError } from './predicates/is-server-or-unknown-error'; 5 | import { RetryHandler } from './retry-handler'; 6 | 7 | jest.mock('./retry-handler'); 8 | 9 | describe('RetryPlugin', () => { 10 | const mockRetryHandler = RetryHandler as jest.Mock; 11 | 12 | it('should create the retry handler with default options', () => { 13 | const plugin = createRetryPlugin(); 14 | 15 | mockRetryHandler.mockReturnValue(EMPTY); 16 | 17 | expect(plugin.shouldHandleRequest).toBeUndefined(); 18 | expect(RetryHandler).toHaveBeenCalledWith({ 19 | initialInterval: 300, 20 | maxInterval: 10000, 21 | maxRetries: 3, 22 | shouldRetry: isServerOrUnknownError, 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/create-retry-plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrRequest, createRequest, createResponse } from '@convoyr/core'; 2 | import { createPluginTester } from '@convoyr/core/testing'; 3 | import { marbles } from 'rxjs-marbles/jest'; 4 | import { TestScheduler } from 'rxjs/testing'; 5 | import { createRetryPlugin } from './create-retry-plugin'; 6 | import { isServerOrUnknownError } from './predicates/is-server-or-unknown-error'; 7 | 8 | describe('RetryPlugin', () => { 9 | let request: ConvoyrRequest; 10 | 11 | beforeEach(() => { 12 | request = createRequest({ 13 | url: 'https://ultimate-answer.com', 14 | }); 15 | }); 16 | 17 | it( 18 | 'should retry the handler with back-off strategy when a server error occurs', 19 | marbles((m) => { 20 | /* Setting every frame duration to 100ms. */ 21 | TestScheduler['frameTimeFactor'] = 100; 22 | 23 | const pluginTester = createPluginTester({ 24 | plugin: createRetryPlugin({ 25 | initialInterval: 100, 26 | maxInterval: 10_000, 27 | maxRetries: 3, 28 | shouldRetry: isServerOrUnknownError, 29 | }), 30 | }); 31 | 32 | /* Create an error response */ 33 | const response = createResponse({ 34 | status: 500, 35 | statusText: 'Internal Server Error', 36 | }); 37 | 38 | /* Simulate failure response */ 39 | const response$ = m.cold('-#', undefined, response); 40 | const httpHandlerMock = pluginTester.createHttpHandlerMock({ 41 | response: response$, 42 | }); 43 | 44 | const source$ = pluginTester.handleFake({ 45 | request, 46 | httpHandlerMock, 47 | }); 48 | 49 | const expected$ = m.cold('-----------#', undefined, response); 50 | m.expect(source$).toBeObservable(expected$); 51 | m.expect(response$).toHaveSubscriptions([ 52 | /* First try. */ 53 | '^!', 54 | /* First retry after 100ms which makes it happen in frame 2 (200ms): 100ms (error response delay) + 100ms. */ 55 | '--^!', 56 | /* Second retry after 200ms which makes it happen in frame 5 (500ms): 200ms + 100ms (response delay) + 200ms. */ 57 | '-----^!', 58 | /* Third retry after 400ms which makes it happen in frame 10 (1s): 500ms + 100ms (response delay) + 400ms. */ 59 | '----------^!', 60 | ]); 61 | }) 62 | ); 63 | 64 | it( 65 | `should retry only when its 5xx or unknown errors`, 66 | marbles((m) => { 67 | const pluginTester = createPluginTester({ 68 | plugin: createRetryPlugin({ 69 | initialInterval: 100, 70 | maxInterval: 10_000, 71 | maxRetries: 3, 72 | shouldRetry: isServerOrUnknownError, 73 | }), 74 | }); 75 | 76 | /* Create a 404 response */ 77 | const response = createResponse({ 78 | status: 404, 79 | statusText: 'Resource not found', 80 | }); 81 | 82 | /* Simulate failure response */ 83 | const response$ = m.cold('-#', undefined, response); 84 | const httpHandlerMock = pluginTester.createHttpHandlerMock({ 85 | response: response$, 86 | }); 87 | 88 | const source$ = pluginTester.handleFake({ 89 | request, 90 | httpHandlerMock, 91 | }); 92 | 93 | const expected$ = m.cold('-#', undefined, response); 94 | m.expect(source$).toBeObservable(expected$); 95 | m.expect(response$).toHaveSubscriptions(['^!']); 96 | }) 97 | ); 98 | }); 99 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/create-retry-plugin.ts: -------------------------------------------------------------------------------- 1 | import { RequestCondition } from '@convoyr/core'; 2 | import { isServerOrUnknownError } from './predicates/is-server-or-unknown-error'; 3 | import { HandlerOptions, RetryHandler } from './retry-handler'; 4 | 5 | export interface RetryPluginOptions extends HandlerOptions { 6 | shouldHandleRequest: RequestCondition; 7 | } 8 | 9 | /** 10 | * @param shouldHandleRequest 11 | * @param initialInterval defaults to 300ms 12 | * @param maxInterval defaults to 10s 13 | * @param maxRetries defaults to 3 14 | * @param shouldRetry defaults to server error: 5xx 15 | */ 16 | export function createRetryPlugin({ 17 | shouldHandleRequest, 18 | initialInterval = 300, 19 | maxInterval = 10_000, 20 | maxRetries = 3, 21 | shouldRetry = isServerOrUnknownError, 22 | }: Partial = {}) { 23 | return { 24 | shouldHandleRequest, 25 | handler: new RetryHandler({ 26 | initialInterval, 27 | maxInterval, 28 | maxRetries, 29 | shouldRetry, 30 | }), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-server-error.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResponse, ConvoyrResponse } from '@convoyr/core'; 2 | 3 | import { isServerError } from './is-server-error'; 4 | 5 | describe.each<[ConvoyrResponse, boolean]>([ 6 | [ 7 | createResponse({ 8 | status: 500, 9 | statusText: 'Internal Server Error', 10 | }), 11 | true, 12 | ], 13 | [ 14 | createResponse({ 15 | status: 200, 16 | statusText: 'Ok', 17 | }), 18 | false, 19 | ], 20 | [ 21 | createResponse({ 22 | status: 400, 23 | statusText: 'Bad Request', 24 | }), 25 | false, 26 | ], 27 | [ 28 | createResponse({ 29 | status: 304, 30 | statusText: 'Not Modified', 31 | }), 32 | false, 33 | ], 34 | ])('isServerError with response: %p => %p', (response, expected) => { 35 | it('should check if the response is a server error', () => { 36 | expect(isServerError(response)).toBe(expected); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-server-error.ts: -------------------------------------------------------------------------------- 1 | import { RetryPredicate } from './retry-predicate'; 2 | 3 | export const isServerError: RetryPredicate = response => response.status >= 500; 4 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-server-or-unknown-error.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResponse, ConvoyrResponse } from '@convoyr/core'; 2 | import { isServerOrUnknownError } from './is-server-or-unknown-error'; 3 | 4 | describe.each<[ConvoyrResponse, boolean]>([ 5 | [ 6 | createResponse({ 7 | status: 0, 8 | statusText: 'Unknown Error', 9 | }), 10 | true, 11 | ], 12 | [ 13 | createResponse({ 14 | status: 200, 15 | statusText: 'Ok', 16 | }), 17 | false, 18 | ], 19 | ])('isServerOrUnknownError with response: %p => %p', (response, expected) => { 20 | it('should check if the response is a server or an unknown error', () => { 21 | expect(isServerOrUnknownError(response)).toBe(expected); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-server-or-unknown-error.ts: -------------------------------------------------------------------------------- 1 | import { RetryPredicate } from './retry-predicate'; 2 | import { isServerError } from './is-server-error'; 3 | import { isUnknownError } from './is-unknown-error'; 4 | 5 | export const or = ( 6 | ...predicates: ((...args: TArgs) => boolean)[] 7 | ) => (...args: TArgs) => { 8 | return predicates.some(predicate => predicate(...args)); 9 | }; 10 | 11 | export const isServerOrUnknownError: RetryPredicate = response => { 12 | return or(isServerError, isUnknownError)(response); 13 | }; 14 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-unknown-error.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResponse, ConvoyrResponse } from '@convoyr/core'; 2 | import { isUnknownError } from './is-unknown-error'; 3 | 4 | describe.each<[ConvoyrResponse, boolean]>([ 5 | [ 6 | createResponse({ 7 | status: 0, 8 | statusText: 'Unknown Error', 9 | }), 10 | true, 11 | ], 12 | [ 13 | createResponse({ 14 | status: 200, 15 | statusText: 'Ok', 16 | }), 17 | false, 18 | ], 19 | ])('isUnknownError with response: %p => %p', (response, expected) => { 20 | it('should check if the response is an unknown error', () => { 21 | expect(isUnknownError(response)).toBe(expected); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/is-unknown-error.ts: -------------------------------------------------------------------------------- 1 | import { RetryPredicate } from './retry-predicate'; 2 | 3 | export const isUnknownError: RetryPredicate = response => response.status === 0; 4 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/predicates/retry-predicate.ts: -------------------------------------------------------------------------------- 1 | import { ConvoyrResponse } from '@convoyr/core'; 2 | 3 | export type RetryPredicate = (response: ConvoyrResponse) => boolean; 4 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/lib/retry-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConvoyrResponse, 3 | PluginHandler, 4 | PluginHandlerArgs, 5 | } from '@convoyr/core'; 6 | import { retryBackoff } from 'backoff-rxjs'; 7 | import { Observable } from 'rxjs'; 8 | import { RetryPredicate } from './predicates/retry-predicate'; 9 | 10 | export interface HandlerOptions { 11 | /** 12 | * Initial retry interval in milliseconds. 13 | */ 14 | initialInterval: number; 15 | /** 16 | * Last retry interval in milliseconds. 17 | */ 18 | maxInterval: number; 19 | maxRetries: number; 20 | shouldRetry: RetryPredicate; 21 | } 22 | 23 | export class RetryHandler implements PluginHandler { 24 | private _initialInterval: number; 25 | private _maxInterval: number; 26 | private _maxRetries: number; 27 | private _shouldRetry: RetryPredicate; 28 | 29 | constructor({ 30 | initialInterval, 31 | maxInterval, 32 | maxRetries, 33 | shouldRetry, 34 | }: HandlerOptions) { 35 | this._initialInterval = initialInterval; 36 | this._maxInterval = maxInterval; 37 | this._maxRetries = maxRetries; 38 | this._shouldRetry = shouldRetry; 39 | } 40 | 41 | handle({ request, next }: PluginHandlerArgs): Observable { 42 | return next.handle({ request }).pipe( 43 | retryBackoff({ 44 | initialInterval: this._initialInterval, 45 | maxInterval: this._maxInterval, 46 | maxRetries: this._maxRetries, 47 | shouldRetry: this._shouldRetry, 48 | }) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /libs/plugin-retry/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | -------------------------------------------------------------------------------- /libs/plugin-retry/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node", "jest"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /libs/plugin-retry/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "declaration": true, 8 | "inlineSources": true, 9 | "types": [], 10 | "lib": ["dom", "es2018"], 11 | "paths": { 12 | "@convoyr/core": ["libs/core/dist"] 13 | } 14 | }, 15 | "angularCompilerOptions": { 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "exclude": ["src/test-setup.ts", "**/*.spec.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /libs/plugin-retry/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /libs/plugin-retry/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /libs/plugin-retry/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "convoyr", "camelCase"], 5 | "component-selector": [true, "element", "convoyr", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/logo.png -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "convoyr", 3 | "implicitDependencies": { 4 | "angular.json": "*", 5 | "package.json": "*", 6 | "tsconfig.json": "*", 7 | "tslint.json": "*", 8 | "nx.json": "*" 9 | }, 10 | "projects": { 11 | "sandbox-e2e": { 12 | "tags": [] 13 | }, 14 | "sandbox": { 15 | "tags": [], 16 | "implicitDependencies": ["sandbox-api"] 17 | }, 18 | "core": { 19 | "tags": [] 20 | }, 21 | "plugin-cache": { 22 | "tags": [] 23 | }, 24 | "angular": { 25 | "tags": [] 26 | }, 27 | "plugin-retry": { 28 | "tags": [] 29 | }, 30 | "sandbox-api": { 31 | "tags": [] 32 | }, 33 | "plugin-auth": { 34 | "tags": [] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convoyr", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "nx": "nx", 8 | "start": "run-p start:*", 9 | "start:app": "ng serve --hmr", 10 | "start:api": "ng serve sandbox-api", 11 | "build": "ng build", 12 | "test": "ng test", 13 | "test:all": "yarn affected:test --all --parallel --maxParallel 100 --watch", 14 | "lint": "nx workspace-lint && ng lint", 15 | "e2e": "ng e2e", 16 | "affected:apps": "nx affected:apps", 17 | "affected:libs": "nx affected:libs", 18 | "affected:build": "nx affected:build", 19 | "affected:e2e": "nx affected:e2e", 20 | "affected:test": "nx affected:test", 21 | "affected:lint": "nx affected:lint", 22 | "affected:dep-graph": "nx affected:dep-graph", 23 | "affected": "nx affected", 24 | "format": "nx format:write", 25 | "format:write": "nx format:write", 26 | "format:check": "nx format:check", 27 | "limbo": "tools/limbo.sh", 28 | "tcr": "yarn affected:test --uncommitted && git recommit || git reset --hard", 29 | "merge-into-master": "tools/merge-into-master.sh", 30 | "publish:libs": "lerna publish --dist-tag latest --yes", 31 | "update": "ng update @nrwl/workspace", 32 | "update:check": "ng update", 33 | "workspace-schematic": "nx workspace-schematic", 34 | "dep-graph": "nx dep-graph", 35 | "help": "nx help", 36 | "contributors:add": "all-contributors add" 37 | }, 38 | "private": true, 39 | "dependencies": { 40 | "@angular/animations": "^11.0.1", 41 | "@angular/cdk": "^11.0.0", 42 | "@angular/common": "^11.0.1", 43 | "@angular/compiler": "^11.0.1", 44 | "@angular/core": "^11.0.1", 45 | "@angular/flex-layout": "^9.0.0-beta.31", 46 | "@angular/forms": "^11.0.1", 47 | "@angular/material": "^11.0.0", 48 | "@angular/platform-browser": "^11.0.1", 49 | "@angular/platform-browser-dynamic": "^11.0.1", 50 | "@angular/router": "^11.0.1", 51 | "@nrwl/angular": "^11.0.0", 52 | "body-parser": "^1.19.0", 53 | "core-js": "^3.6.4", 54 | "cors": "^2.8.5", 55 | "express": "4.20.0", 56 | "rxjs": "~6.6.0", 57 | "tslib": "^2.0.0", 58 | "zone.js": "~0.11.0" 59 | }, 60 | "devDependencies": { 61 | "@angular-devkit/build-angular": "~0.1102.0", 62 | "@angular/cli": "11.2.15", 63 | "@angular/compiler-cli": "^11.0.1", 64 | "@angular/language-service": "^11.0.1", 65 | "@commitlint/cli": "12.1.4", 66 | "@commitlint/config-angular": "^12.0.0", 67 | "@commitlint/config-conventional": "^12.0.0", 68 | "@hirez_io/observer-spy": "^2.0.0", 69 | "@nrwl/cypress": "11.3.1", 70 | "@nrwl/express": "^11.0.0", 71 | "@nrwl/jest": "11.3.1", 72 | "@nrwl/node": "11.3.1", 73 | "@nrwl/workspace": "11.3.1", 74 | "@types/express": "4.17.13", 75 | "@types/jest": "27.0.3", 76 | "@types/lru-cache": "^5.1.0", 77 | "@types/node": "^16.0.0", 78 | "all-contributors-cli": "^6.12.0", 79 | "codelyzer": "^6.0.0", 80 | "cypress": "6.9.1", 81 | "dotenv": "10.0.0", 82 | "eslint": "7.32.0", 83 | "husky": "^6.0.0", 84 | "jest": "27.4.3", 85 | "jest-date-mock": "^1.0.8", 86 | "jest-preset-angular": "8.4.0", 87 | "lerna": "^4.0.0", 88 | "lint-staged": "11.1.2", 89 | "ng-packagr": "^11.0.0", 90 | "npm-run-all": "^4.1.5", 91 | "prettier": "2.5.1", 92 | "rxjs-marbles": "^7.0.0", 93 | "ts-jest": "27.1.5", 94 | "ts-node": "~9.1.0", 95 | "tsickle": "^0.43.0", 96 | "tslint": "~6.1.0", 97 | "typescript": "~4.1.0" 98 | }, 99 | "workspaces": [ 100 | "libs/*" 101 | ], 102 | "husky": { 103 | "hooks": { 104 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 105 | "pre-commit": "lint-staged" 106 | } 107 | }, 108 | "lint-staged": { 109 | "*.ts": [ 110 | "prettier --write", 111 | "tslint --fix" 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tools/limbo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while :; 4 | do 5 | git pull --rebase 6 | git push 7 | done -------------------------------------------------------------------------------- /tools/schematics/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscutlery/convoyr/9385f4a5284e4385b0db578254e124002cf86faf/tools/schematics/.gitkeep -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "es2020", 13 | "typeRoots": ["node_modules/@types"], 14 | "lib": ["es2017", "dom"], 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@convoyr/angular": ["libs/angular/src/index.ts"], 20 | "@convoyr/core": ["libs/core/src/index.ts"], 21 | "@convoyr/core/testing": ["libs/core/testing/src/index.ts"], 22 | "@convoyr/plugin-auth": ["libs/plugin-auth/src/index.ts"], 23 | "@convoyr/plugin-cache": ["libs/plugin-cache/src/index.ts"], 24 | "@convoyr/plugin-retry": ["libs/plugin-retry/src/index.ts"] 25 | } 26 | }, 27 | "exclude": ["node_modules", "tmp"] 28 | } 29 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/@nrwl/workspace/src/tslint", 4 | "node_modules/codelyzer" 5 | ], 6 | "rules": { 7 | "arrow-return-shorthand": true, 8 | "callable-types": true, 9 | "class-name": true, 10 | "deprecation": { 11 | "severity": "warn" 12 | }, 13 | "forin": true, 14 | "import-blacklist": [true, "rxjs/Rx"], 15 | "interface-over-type-literal": true, 16 | "member-access": false, 17 | "member-ordering": [ 18 | true, 19 | { 20 | "order": [ 21 | "static-field", 22 | "instance-field", 23 | "static-method", 24 | "instance-method" 25 | ] 26 | } 27 | ], 28 | "no-arg": true, 29 | "no-bitwise": true, 30 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 31 | "no-construct": true, 32 | "no-debugger": true, 33 | "no-duplicate-super": true, 34 | "no-empty": false, 35 | "no-empty-interface": true, 36 | "no-eval": true, 37 | "no-inferrable-types": [true, "ignore-params"], 38 | "no-misused-new": true, 39 | "no-non-null-assertion": true, 40 | "no-shadowed-variable": true, 41 | "no-string-literal": false, 42 | "no-string-throw": true, 43 | "no-switch-case-fall-through": true, 44 | "no-unnecessary-initializer": true, 45 | "no-unused-expression": true, 46 | "no-var-keyword": true, 47 | "object-literal-sort-keys": false, 48 | "prefer-const": true, 49 | "radix": true, 50 | "triple-equals": [true, "allow-null-check"], 51 | "unified-signatures": true, 52 | "variable-name": false, 53 | "directive-selector": [true, "attribute", "app", "camelCase"], 54 | "component-selector": [true, "element", "app", "kebab-case"], 55 | "no-conflicting-lifecycle": true, 56 | "no-host-metadata-property": true, 57 | "no-input-rename": true, 58 | "no-inputs-metadata-property": true, 59 | "no-output-native": true, 60 | "no-output-on-prefix": true, 61 | "no-output-rename": true, 62 | "no-outputs-metadata-property": true, 63 | "template-banana-in-box": true, 64 | "template-no-negated-async": true, 65 | "use-lifecycle-interface": true, 66 | "use-pipe-transform-interface": true 67 | } 68 | } 69 | --------------------------------------------------------------------------------