├── .azure └── coverage.yml ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.mjs ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest-setup.js ├── jest.config.js ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── examples │ ├── LICENSE │ ├── package.json │ ├── src │ │ ├── PokemonApi.ts │ │ ├── index.ts │ │ └── types.ts │ └── tsconfig.json ├── httpclient-axios │ ├── CHANGELOG.md │ ├── LICENSE │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── examples │ │ │ └── example-axios-options.ts │ │ ├── index.ts │ │ ├── normalizeHeaders.test.ts │ │ └── normalizeHeaders.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ └── tsconfig.json └── httpclient │ ├── CHANGELOG.md │ ├── LICENSE │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── Adaptors │ │ └── index.ts │ ├── FetchClientAdaptor.ts │ ├── HttpClient.test.ts │ ├── HttpClient.ts │ ├── HttpRequestStrategies │ │ ├── DefaultHttpRequestStrategy.test.ts │ │ ├── DefaultHttpRequestStrategy.ts │ │ ├── ExponentialBackoffRequestStrategy.test.ts │ │ ├── ExponentialBackoffRequestStrategy.ts │ │ ├── HttpRequestStrategy.ts │ │ ├── MaxRetryHttpRequestStrategy.test.ts │ │ ├── MaxRetryHttpRequestStrategy.ts │ │ ├── TimeoutHttpRequestStrategy.test.ts │ │ ├── TimeoutHttpRequestStrategy.ts │ │ └── index.ts │ ├── Logger.ts │ ├── errors │ │ ├── AbortError.ts │ │ ├── HttpError.ts │ │ ├── TimeoutError.ts │ │ ├── isHttpError.test.ts │ │ └── isHttpError.ts │ ├── examples │ │ ├── example-basic.ts │ │ └── example-cancelToken.ts │ ├── index.ts │ ├── strings.ts │ └── utilities │ │ ├── getIsSuccessfulHttpStatus.test.ts │ │ ├── getIsSuccessfulHttpStatus.ts │ │ ├── sleep.test.ts │ │ └── sleep.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json └── tsconfig.test.json /.azure/coverage.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | - main 3 | 4 | pool: 5 | vmImage: ubuntu-latest 6 | 7 | steps: 8 | - task: NodeTool@0 9 | inputs: 10 | versionSpec: '20.x' 11 | checkLatest: true 12 | - script: npm ci 13 | displayName: 'Install dependencies' 14 | - script: npm run lint 15 | displayName: 'Lint code' 16 | - script: npm run test:coverage 17 | displayName: 'Run tests and collect coverage' 18 | - script: npm run build 19 | displayName: 'Build project' 20 | - task: PublishCodeCoverageResults@2 21 | inputs: 22 | summaryFileLocation: 'coverage/cobertura-coverage.xml' 23 | pathToSources: './src' 24 | failIfCoverageEmpty: true 25 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | out 4 | build 5 | *.log 6 | **/dist 7 | **/node_modules 8 | **/out 9 | **/build 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | ], 11 | parser: '@typescript-eslint/parser', 12 | parserOptions: { 13 | ecmaVersion: 2021, 14 | parser: '@typescript-eslint/parser', 15 | sourceType: 'module', 16 | impliedStrict: true, 17 | }, 18 | plugins: [ 19 | '@typescript-eslint', 20 | 'prettier', 21 | ], 22 | rules: { 23 | 'no-unused-vars': 0, 24 | 'complexity': ['error', 15], 25 | eqeqeq: ['error', 'always'], 26 | 'no-var': 2, 27 | 'prefer-spread': 2, 28 | 'prefer-template': 2, 29 | 'no-duplicate-imports': 1, 30 | 'prefer-rest-params': 2, 31 | 'prefer-arrow-callback': 2, 32 | 'no-const-assign': 2, 33 | 'no-multiple-empty-lines': 2, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [main] 10 | pull_request: 11 | branches: [main] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v4 27 | name: Checkout 28 | - uses: actions/setup-node@v4 29 | name: Setup Node 30 | with: 31 | node-version: "20" 32 | check-latest: true 33 | cache: "npm" 34 | - run: npm ci 35 | name: Install 36 | - run: npm run test:unit 37 | name: Test 38 | - run: npm run lint 39 | name: Lint 40 | - run: npm run build 41 | name: Build 42 | # - uses: JS-DevTools/npm-publish@v3 43 | # name: Publish to npm 44 | # with: 45 | # token: ${{ secrets.NPMJS_TOKEN }} 46 | # registry: https://registry.npmjs.org/ 47 | # - uses: JS-DevTools/npm-publish@v3 48 | # name: Publish to GitHub 49 | # with: 50 | # token: ${{ secrets.GH_TOKEN }} 51 | # registry: https://npm.pkg.github.com 52 | - name: Set git identity 53 | run: | 54 | git config --global user.name 'github-actions[bot]' 55 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 56 | git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/$GITHUB_REPOSITORY 57 | env: 58 | NODE_AUTH_TOKEN: ${{ secrets.GH_TOKEN }} 59 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 60 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 61 | 62 | - name: Publish 63 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 64 | run: HUSKY_SKIP_HOOKS=1 npx lerna publish from-package --yes 65 | env: 66 | NODE_AUTH_TOKEN: ${{ secrets.NPMJS_TOKEN }} 67 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 68 | NPMJS_TOKEN: ${{ secrets.NPMJS_TOKEN }} 69 | NPM_TOKEN: ${{ secrets.NPMJS_TOKEN }} 70 | 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | coverage 4 | junit.xml 5 | 6 | dist 7 | .DS_Store 8 | 9 | .yarn/cache -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | build/ 5 | temp/ 6 | out/ 7 | generated/ 8 | .nx/ 9 | *.log 10 | *.lock 11 | *.swp 12 | *.swo 13 | *.swn 14 | *.tgz 15 | *.tar.gz 16 | *.tar 17 | *.zip 18 | *.rar 19 | .env 20 | .env* 21 | *.yml 22 | *.yaml 23 | *.json 24 | *.map 25 | *.tsbuildinfo 26 | *.js.map 27 | translations/ 28 | intl/ 29 | static/ 30 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest Current file", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 11 | "--runInBand" 12 | ], 13 | "args": [ 14 | "${fileBasenameNoExtension}", 15 | "--config", 16 | "jest.config.js", 17 | "--coverage", 18 | "false" 19 | ], 20 | "console": "integratedTerminal", 21 | "internalConsoleOptions": "neverOpen", 22 | "autoAttachChildProcesses": true 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # @seriouslag/httpclient 2 | 3 | The monorepo for the `@seriouslag/httpclient` package. 4 | 5 | ## Contributing 6 | 7 | ### Requirements 8 | 9 | _Node_: Check that node is installed with version 14.x or greater. Node's version can be checked with 10 | 11 | ```bash 12 | # Check node version 13 | node -v 14 | ``` 15 | 16 | ### Setup 17 | 18 | ```bash 19 | # Installs all dependencies 20 | npm install 21 | ``` 22 | 23 | ### Build 24 | 25 | ```bash 26 | npm run build 27 | ``` 28 | 29 | ### Testing 30 | 31 | ```bash 32 | # Runs all unit tests 33 | npm run test 34 | ``` 35 | 36 | ```bash 37 | # Runs all unit tests and builds a coverage report. Results are found in ./coverage folder. 38 | npm run test:coverage 39 | ``` 40 | 41 | ### Linting 42 | 43 | ```bash 44 | # Lints all JS/TS files 45 | npm run lint 46 | ``` 47 | 48 | ```bash 49 | # Lints and attempts to fix common linting errors in JS/TS files 50 | npm run lint:fix 51 | ``` 52 | 53 | ### Commiting code 54 | 55 | Always work from a new branch off of main or fork this repo. 56 | 57 | > Rebase often! This will help avoid merge large merge conflicts later. 58 | > 59 | > ```bash 60 | > git fetch origin main 61 | > git checkout origin main 62 | > git pull 63 | > git switch - 64 | > git rebase origin/main 65 | > ``` 66 | 67 | Pull requests (PR) must go through the build pipeline and peer reviewed before merging into main.\ 68 | Keep PRs small so they are easier to get through review. 69 | 70 | ### Debugging code 71 | 72 | This repository comes with a `.vscode/launch.json` file.\ 73 | This can be used to run [debugging sesions using vscode](https://code.visualstudio.com/docs/editor/debugging). 74 | 75 | --- 76 | 77 | [README](./README.md) 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Landon Gavin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | HttpClient 3 |

4 | 5 |

6 | Typed wrapper around fetch or axios. 7 |

8 | 9 |

10 | 11 | Github - Action 12 | 13 | 14 | NPM Package 15 | 16 | 17 | Code Coverage 18 | 19 | 20 | Sonar Violations 21 | 22 |

23 | 24 |

25 | This package's API is still developing and will not follow SEMVER until release 1.0.0. 26 | 27 | HttpClient helps standardizes making HTTP calls regardless of the underlying client used, (fetch is used by default but other clients are available) and handling when errors are thrown. HttpClient works both in the browser and node environments. Exposes an interface to abort HTTP calls using AbortController. See below about using [AbortController](#using-abortcontroller) in older environments. Exposes an interface to control how requests and responses are handled. See below about using [HttpClient's Request Strategies](#using-request-strategies). Some strategies are provided in this package, but you can also implement your own strategies. List of strategies are provided below. 28 | 29 |

30 | 31 |

Installation

32 | 33 | ```bash 34 | npm install @seriouslag/httpclient 35 | ``` 36 | 37 |

Example

38 | 39 |

To see additional examples look in the `src/examples/` directory.

40 | 41 | Basic example: 42 | 43 | ```typescript 44 | import { HttpClient } from '@seriouslag/httpclient'; 45 | 46 | interface NamedLink { 47 | name: string; 48 | url: string; 49 | } 50 | 51 | interface PokemonPage { 52 | count: number; 53 | next: string | null; 54 | previous: string | null; 55 | results: NamedLink[]; 56 | } 57 | 58 | const httpClient = new HttpClient(); 59 | 60 | async function fetchPokemonPage(offset: number = 0, pageSize: number = 20) { 61 | const pokemonApiUrl = 'https://pokeapi.co/api/v2'; 62 | return await this.httpClient.get(`${pokemonApiUrl}/pokemon`, { 63 | params: { 64 | offset: offset, 65 | limit: pageSize, 66 | }, 67 | }); 68 | } 69 | 70 | // IIFE 71 | (async () => { 72 | const results = await fetchPokemonPage(0, 100); 73 | console.log(results); 74 | })(); 75 | ``` 76 | 77 |

Using axios

78 | 79 | We can use axios as the underlying client by installing the `@seriouslag/httpclient-axios` package. 80 | A custom client adaptor can be provided to the HttpClient constructor, an interface is exposed to allow for custom client adaptors to be created. 81 | 82 | ```bash 83 | npm install @seriouslag/httpclient @seriouslag/httpclient-axios 84 | ``` 85 | 86 |

87 | Axios can be configured, axios options can be passed into the constructor of HttpClient. 88 |

89 | 90 | ```typescript 91 | import { HttpClient } from '@seriouslag/httpclient'; 92 | import { AxiosClientAdaptor } from '@seriouslag/httpclient-axios'; 93 | import { Agent } from 'https'; 94 | 95 | const httpsAgent = new Agent({ 96 | rejectUnauthorized: false, 97 | }); 98 | 99 | const axiosClientAdaptor = new AxiosClientAdaptor({ 100 | httpsAgent, 101 | }); 102 | 103 | const httpClient = new HttpClient(axiosClientAdaptor); 104 | ``` 105 | 106 |

Using AbortController

107 |

Each of the HTTP methods of the HttpClient accept an instance of a AbortController. This allows HTTP requests to be cancelled if not already resolved. 108 | 109 | ```typescript 110 | import { HttpClient } from '@seriouslag/httpclient'; 111 | 112 | interface PokemonPage { 113 | count: number; 114 | next: string | null; 115 | previous: string | null; 116 | results: NamedLink[]; 117 | } 118 | 119 | const pokemonApiUrl = 'https://pokeapi.co/api/v2'; 120 | const httpClient = new HttpClient(); 121 | const cancelToken = new AbortController(); 122 | 123 | const request = httpClient.get( 124 | `${pokemonApiUrl}/pokemon`, 125 | cancelToken, 126 | ); 127 | 128 | cancelToken.abort(); 129 | 130 | try { 131 | const result = await request; 132 | console.log('Expect to not get here because request was aborted.', result); 133 | } catch (e) { 134 | console.log('Expect to reach here because request was aborted.'); 135 | } 136 | ``` 137 | 138 |

139 | 140 |

AbortController in older environments

141 |

142 | Abort controller is native to node 15+ and modern browsers. If support is needed for older browsers/node versions then polyfills can be found. This polyfill is used in the Jest test environment for this repository: abortcontroller-polyfill 143 | 144 | ```typescript 145 | import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; 146 | import { HttpClient } from '@seriouslag/httpclient'; 147 | 148 | const httpClient = new HttpClient(); 149 | ``` 150 | 151 |

152 | 153 |

Using Request Strategies

154 |

155 | A request strategy is middleware to handle how requests are made and how responses are handled. This is exposed to the consumer using the `HttpRequestStrategy` interface. A request strategy can be passed into the HttpClient (it will be defaulted if not) or it can be passed into each request (if not provided then the strategy provided by the HttpClient will be used). A custom strategy can be provided to the HttpClient's constructor. 156 | 157 | Provided strategies: 158 | 159 |

165 |

166 | 167 |

Using Request Strategy in the constructor

168 | 169 |

The following code creates an instance of the HttpClient with a custom HttpRequestStrategy, all requests will now use this strategy by default.

170 | 171 | ```typescript 172 | import { HttpClient, HttpRequestStrategy } from '@seriouslag/httpclient'; 173 | 174 | class CreatedHttpRequestStrategy implements HttpRequestStrategy { 175 | 176 | /** Passthrough request to axios and check response is created status */ 177 | public async request (client: AxiosInstance, axiosConfig: AxiosRequestConfig) { 178 | const response = await client.request(axiosConfig); 179 | this.checkResponseStatus(response); 180 | return response; 181 | } 182 | 183 | /** Validates the HTTP response is successful created status or throws an error */ 184 | private checkResponseStatus (response: HttpResponse): HttpResponse { 185 | const isCreatedResponse = response.status === 201; 186 | if (isCreatedResponse) { 187 | return response; 188 | } 189 | throw response; 190 | } 191 | } 192 | 193 | const httpRequestStrategy = new CreatedHttpRequestStrategy(); 194 | 195 | // all requests will now throw unless they return an HTTP response with a status of 201 196 | const httpClient = new HttpClient({ 197 | httpRequestStrategy, 198 | }); 199 | 200 | ```` 201 | 202 |

Using Request Strategy in a request

203 | 204 |

The following code creates an instance of the HttpClient with a provided HttpRequestStrategy (MaxRetryHttpRequestStrategy), then starts a request and passes a different strategy (DefaultHttpRequestStrategy) to the request. The request will now used the strategy provided instead of the HttpClients strategy.

205 | 206 | ```typescript 207 | import { HttpClient, DefaultHttpRequestStrategy, MaxRetryHttpRequestStrategy } from '@seriouslag/httpclient'; 208 | 209 | const httpClient = new HttpClient({ 210 | httpRequestStrategy: new MaxRetryHttpRequestStrategy(10), 211 | }); 212 | 213 | // IIFE 214 | (async () => { 215 | const response = await httpClient.get('/endpoint', { 216 | httpRequestStrategy: new DefaultHttpRequestStrategy(), 217 | }); 218 | })(); 219 | ```` 220 | 221 |

222 | 223 |

Logging

224 |

An interface is exposed to the HttpClient constructor to allow a logging instance to be provided. 225 | 226 | ```typescript 227 | const logger: Logger = { 228 | info: (message: string, ...args: unknown[]) => console.log(message, ...args), 229 | warn: (message: string, ...args: unknown[]) => console.warn(message, ...args), 230 | error: (message: string, ...args: unknown[]) => console.error(message, ...args), 231 | debug: (message: string, ...args: unknown[]) => console.debug(message, ...args), 232 | }; 233 | 234 | const httpClient = new HttpClient({ 235 | logger, 236 | }); 237 | ``` 238 | 239 |

240 | 241 |

Contributing

242 | 243 | [Contributing](./CONTRIBUTING.md) 244 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // abortcontroller polyfill is needed for node envs less than 15.0 2 | import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; 3 | import jestMock from 'jest-fetch-mock'; 4 | 5 | jestMock.enableMocks(); 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | module.exports = { 3 | verbose: true, 4 | roots: ['/packages'], 5 | preset: 'ts-jest', 6 | transform: { 7 | '^.+\\.(js|ts)$': [ 8 | 'ts-jest', 9 | { 10 | tsconfig: '/tsconfig.test.json', 11 | }, 12 | ], 13 | }, 14 | moduleFileExtensions: ['js', 'ts'], 15 | testEnvironment: 'node', 16 | coverageProvider: 'v8', 17 | reporters: ['jest-junit', 'default'], 18 | coverageReporters: ['cobertura', 'html', 'lcov'], 19 | coverageDirectory: 'coverage', 20 | setupFiles: ['./jest-setup.js'], 21 | collectCoverageFrom: [ 22 | 'packages/**/src/**/*.{js,ts}', 23 | '!packages/examples/**/*', 24 | '!packages/httpclient/src/examples/**/*', 25 | '!packages/httpclient-axios/src/examples/**/*', 26 | ], 27 | testPathIgnorePatterns: ['/out/', '/node_modules/'], 28 | modulePaths: ['/packages/'], 29 | moduleNameMapper: { 30 | '^@seriouslag/(.*)$': ['/packages/$1/src/'], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/lerna", 3 | "version": "independent", 4 | "npmClient": "npm", 5 | "changelogPreset": "angular", 6 | "command": { 7 | "version": { 8 | "createRelease": "npm", 9 | "conventionalCommits": true, 10 | "allowBranch": ["main"], 11 | "message": "chore(release): publish" 12 | }, 13 | "publish": { 14 | "ignoreChanges": [ 15 | "*.md", 16 | "*.test.*" 17 | ], 18 | "message": "chore(release): publish" 19 | }, 20 | "bootstrap": {} 21 | }, 22 | "packages": [ 23 | "packages/*" 24 | ] 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@seriouslag/httpclient_monorepo", 3 | "version": "0.0.0", 4 | "description": "Typed wrapper HttpClient for axios", 5 | "private": true, 6 | "scripts": { 7 | "build": "lerna run build", 8 | "lint": "eslint .", 9 | "package": "npm run build && lerna publish --canary", 10 | "publish": "lerna publish", 11 | "test": "npm run test:unit", 12 | "test:unit": "jest", 13 | "test:coverage": "jest --coverage" 14 | }, 15 | "contributors": [ 16 | { 17 | "name": "Landon Gavin", 18 | "email": "hi@landongavin.com", 19 | "url": "https://landongavin.dev" 20 | } 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/seriouslag/HttpClient.git" 25 | }, 26 | "keywords": [ 27 | "httpClient", 28 | "typescript", 29 | "axios", 30 | "fetch" 31 | ], 32 | "author": "hi@landongavin.com", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/seriouslag/HttpClient/issues" 36 | }, 37 | "workspaces": [ 38 | "packages/*" 39 | ], 40 | "homepage": "https://github.com/seriouslag/HttpClient#readme", 41 | "devDependencies": { 42 | "@rollup/plugin-commonjs": "^25.0.7", 43 | "@rollup/plugin-terser": "^0.4.4", 44 | "@types/jest": "^29.5.12", 45 | "@typescript-eslint/eslint-plugin": "^7.6.0", 46 | "@typescript-eslint/parser": "^7.6.0", 47 | "abortcontroller-polyfill": "^1.7.3", 48 | "axios-mock-adapter": "^1.20.0", 49 | "eslint": "^8.4.1", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-prettier": "^5.1.3", 52 | "jest": "^29.7.0", 53 | "jest-fetch-mock": "^3.0.3", 54 | "jest-junit": "^16.0.0", 55 | "jest-mock": "^29.7.0", 56 | "lerna": "^8.1.2", 57 | "prettier": "^3.2.5", 58 | "rollup": "^4.14.1", 59 | "rollup-plugin-polyfill-node": "^0.13.0", 60 | "rollup-plugin-peer-deps-external": "^2.2.4", 61 | "rollup-plugin-typescript2": "^0.36.0", 62 | "ts-jest": "^29.1.2", 63 | "typescript": "^5.4.5" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/examples/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Landon Gavin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@seriouslag/examples", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Example code", 6 | "scripts": { 7 | }, 8 | "contributors": [ 9 | { 10 | "name": "Landon Gavin", 11 | "email": "hi@landongavin.com", 12 | "url": "https://landongavin.dev" 13 | } 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/seriouslag/HttpClient.git" 18 | }, 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "keywords": [ 23 | "httpClient", 24 | "typescript", 25 | "axios", 26 | "fetch" 27 | ], 28 | "bugs": { 29 | "url": "https://github.com/seriouslag/HttpClient/issues" 30 | }, 31 | "author": "hi@landongavin.com", 32 | "license": "MIT", 33 | "files": [ 34 | ], 35 | "dependencies": { 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/examples/src/PokemonApi.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@seriouslag/httpclient'; 2 | import { PokemonPage } from './types'; 3 | 4 | export class PokemonApi { 5 | private pageSize = 20; 6 | 7 | constructor( 8 | private baseUrl: string, 9 | private httpClient: HttpClient, 10 | ) {} 11 | 12 | /** 13 | * Fetches a page of Pokemon from the API. 14 | */ 15 | public async fetchPokemonPage( 16 | cancelToken?: AbortController, 17 | offset: number = 0, 18 | pageSize: number = this.pageSize, 19 | ): Promise { 20 | const response = await this.httpClient.get( 21 | `${this.baseUrl}/pokemon`, 22 | { 23 | params: { 24 | offset: offset, 25 | limit: pageSize, 26 | }, 27 | }, 28 | cancelToken, 29 | ); 30 | return response; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/examples/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './PokemonApi'; 3 | -------------------------------------------------------------------------------- /packages/examples/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface NamedLink { 2 | name: string; 3 | url: string; 4 | } 5 | 6 | export interface AbilityDescription { 7 | ability: NamedLink; 8 | is_hidden: false; 9 | slot: number; 10 | } 11 | 12 | export interface VersionGroupMethod { 13 | level_learned_at: number; 14 | move_learn_method: NamedLink; 15 | version_group: NamedLink; 16 | } 17 | 18 | export interface MoveDescription { 19 | move: NamedLink; 20 | version_group_method: VersionGroupMethod[]; 21 | } 22 | 23 | export interface TypeDescription { 24 | slot: number; 25 | type: NamedLink; 26 | } 27 | 28 | export interface GameIndice { 29 | game_index: number; 30 | version: NamedLink; 31 | } 32 | 33 | export interface SpriteList { 34 | back_default: string|null; 35 | back_female:null 36 | back_shiny: string|null; 37 | back_shiny_female: string|null; 38 | front_default: string|null; 39 | front_female: string|null; 40 | front_shiny: string|null; 41 | front_shiny_female: string|null; 42 | } 43 | 44 | export interface StatDescription { 45 | base_stat: number; 46 | effort: number; 47 | stat: NamedLink; 48 | } 49 | 50 | export interface SpriteDescription extends SpriteList { 51 | other?: any; 52 | versions?: any; 53 | } 54 | 55 | export interface Pokemon { 56 | abilities: AbilityDescription[]; 57 | base_experience: number; 58 | forms: NamedLink[]; 59 | game_indices: GameIndice[]; 60 | height: number; 61 | held_items: NamedLink[]; 62 | id: number; 63 | is_default: boolean; 64 | location_area_encounters: string; 65 | moves: MoveDescription[]; 66 | name: string; 67 | order: number; 68 | past_types: TypeDescription[]; 69 | species: NamedLink; 70 | sprites: SpriteDescription; 71 | stats: StatDescription[]; 72 | types: TypeDescription[] 73 | } 74 | 75 | export interface PokemonPage { 76 | count: number; 77 | next: string|null; 78 | previous: string|null; 79 | results: NamedLink[]; 80 | } 81 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDir": "./src", 6 | "paths": { 7 | "@seriouslag/*": ["../*/src/index"] 8 | } 9 | }, 10 | "references": [ 11 | { 12 | "path": "../httpclient" 13 | } 14 | ], 15 | "include": [ 16 | "./src/**/*.ts" 17 | ], 18 | "exclude": [ 19 | "dist/**/*", 20 | "node_modules/**/*" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/httpclient-axios/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 | ## 0.0.2 (2024-04-11) 7 | 8 | **Note:** Version bump only for package @seriouslag/httpclient-axios 9 | -------------------------------------------------------------------------------- /packages/httpclient-axios/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Landon Gavin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/httpclient-axios/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@seriouslag/httpclient-axios", 3 | "version": "0.0.5", 4 | "description": "Typed wrapper HttpClient for axios", 5 | "browser": "dist/index.min.cjs", 6 | "module": "dist/index.esm", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "import": "./dist/index.esm", 11 | "require": "./dist/index.cjs" 12 | } 13 | }, 14 | "type": "module", 15 | "types": "dist/index.d.ts", 16 | "scripts": { 17 | "build": "rollup -c", 18 | "example:axios": "ts-node src/examples/example-axios-options.ts" 19 | }, 20 | "contributors": [ 21 | { 22 | "name": "Landon Gavin", 23 | "email": "hi@landongavin.com", 24 | "url": "https://landongavin.dev" 25 | } 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/seriouslag/HttpClient.git" 30 | }, 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "keywords": [ 35 | "httpClient", 36 | "typescript", 37 | "axios", 38 | "fetch" 39 | ], 40 | "author": "hi@landongavin.com", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/seriouslag/HttpClient/issues" 44 | }, 45 | "files": [ 46 | "dist", 47 | "src", 48 | "package.json", 49 | "README.md", 50 | "LICENSE" 51 | ], 52 | "homepage": "https://github.com/seriouslag/HttpClient#readme", 53 | "dependencies": { 54 | "@seriouslag/httpclient": "^0.0.18" 55 | }, 56 | "devDependencies": { 57 | "axios": "^1.6.8" 58 | }, 59 | "peerDependencies": { 60 | "axios": "1.x" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/httpclient-axios/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import NodeBuiltins from 'rollup-plugin-polyfill-node'; 3 | import terser from '@rollup/plugin-terser'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 6 | 7 | const typescriptPlugin = typescript({ 8 | tsconfig: 'tsconfig.build.json', 9 | verbosity: 2 10 | }); 11 | 12 | const nodeBuiltins = NodeBuiltins(); 13 | const commonjsPlugin = commonjs(); 14 | const peerDepsExternalPlugin = peerDepsExternal(); 15 | 16 | export default [ 17 | // CJS 18 | { 19 | input: 'src/index.ts', 20 | output: { 21 | file: 'dist/index.cjs', 22 | format: 'cjs', 23 | name: 'HttpClient-axios', 24 | indent: false, 25 | }, 26 | plugins: [ 27 | peerDepsExternalPlugin, 28 | commonjsPlugin, 29 | typescriptPlugin, 30 | nodeBuiltins, 31 | ], 32 | }, 33 | // cjs minified 34 | { 35 | input: 'src/index.ts', 36 | output: { 37 | file: 'dist/index.min.cjs', 38 | format: 'cjs', 39 | name: 'HttpClient-axios', 40 | indent: false, 41 | }, 42 | plugins: [ 43 | peerDepsExternalPlugin, 44 | commonjsPlugin, 45 | typescriptPlugin, 46 | nodeBuiltins, 47 | terser(), 48 | ], 49 | }, 50 | // ESM 51 | { 52 | input: 'src/index.ts', 53 | output: { 54 | file: 'dist/index.mjs', 55 | format: 'esm', 56 | name: 'HttpClient-axios', 57 | indent: false, 58 | }, 59 | plugins: [ 60 | peerDepsExternalPlugin, 61 | nodeBuiltins, 62 | typescriptPlugin, 63 | ], 64 | }, 65 | ]; 66 | -------------------------------------------------------------------------------- /packages/httpclient-axios/src/examples/example-axios-options.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@seriouslag/httpclient'; 2 | import { PokemonApi } from '@seriouslag/examples'; 3 | import { AxiosClientAdaptor } from '../index'; 4 | import { Agent } from 'https'; 5 | 6 | const httpsAgent = new Agent({ 7 | rejectUnauthorized: false, 8 | }); 9 | 10 | const pokemonApiUrl = 'https://pokeapi.co/api/v2'; 11 | 12 | const httpClientAdaptor = new AxiosClientAdaptor({ 13 | httpsAgent, 14 | }); 15 | const httpClient = new HttpClient(httpClientAdaptor); 16 | const pokemonApi = new PokemonApi(pokemonApiUrl, httpClient); 17 | 18 | const main = async () => { 19 | const result = await pokemonApi.fetchPokemonPage(); 20 | console.log(result); 21 | }; 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /packages/httpclient-axios/src/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { 2 | AxiosInstance, 3 | AxiosRequestConfig, 4 | CancelTokenSource, 5 | } from 'axios'; 6 | import { 7 | Request, 8 | RequestConfig, 9 | HttpResponse, 10 | IHttpClientAdaptor, 11 | Logger, 12 | ABORT_MESSAGE, 13 | AbortError, 14 | HttpHeader, 15 | } from '@seriouslag/httpclient'; 16 | import { normalizeAxiosHeaders } from './normalizeHeaders'; 17 | 18 | class AxiosRequest implements Request { 19 | constructor( 20 | private client: AxiosInstance, 21 | private config: RequestConfig, 22 | private axiosConfig: AxiosRequestConfig, 23 | ) {} 24 | 25 | public async do(): Promise> { 26 | const { CancelToken } = axios; 27 | 28 | const { noGlobal, cancelToken } = this.config; 29 | const client = noGlobal ? axios.create() : this.client; 30 | const source = CancelToken.source(); 31 | 32 | let hasResolvedRequest = false; 33 | let hasCanceled = false; 34 | // bind cancel token 35 | if (cancelToken) { 36 | // if signal is already aborted then cancel the axios source 37 | if (cancelToken.signal.aborted) { 38 | hasCanceled = true; 39 | source.cancel(ABORT_MESSAGE); 40 | throw new AbortError(ABORT_MESSAGE); 41 | } 42 | cancelToken.signal.addEventListener( 43 | 'abort', 44 | () => { 45 | // do not cancel if already canceled 46 | if (hasCanceled) return; 47 | // do not cancel if request is already resolved 48 | if (hasResolvedRequest) return; 49 | // if signal is aborted then cancel the axios source 50 | source.cancel(ABORT_MESSAGE); 51 | hasCanceled = true; 52 | // remove the event listener 53 | cancelToken.signal.removeEventListener('abort', () => {}); 54 | }, 55 | { 56 | once: true, 57 | }, 58 | ); 59 | } 60 | 61 | const response = await this.handleRequest(client, source); 62 | 63 | // get all axios headers as a record 64 | const headers = normalizeAxiosHeaders(response.headers); 65 | 66 | hasResolvedRequest = true; 67 | const formattedResponse: HttpResponse = { 68 | data: response.data, 69 | headers, 70 | status: response.status, 71 | statusText: response.statusText, 72 | }; 73 | return formattedResponse; 74 | } 75 | 76 | private async handleRequest( 77 | client: AxiosInstance, 78 | source: CancelTokenSource, 79 | ) { 80 | try { 81 | const response = await client.request({ 82 | ...this.axiosConfig, 83 | cancelToken: source.token, 84 | }); 85 | return response; 86 | } catch (e) { 87 | // if request is canceled then throw an abort error, keeps the error handling consistent 88 | if (axios.isCancel(e)) { 89 | throw new AbortError(ABORT_MESSAGE); 90 | } 91 | throw e; 92 | } 93 | } 94 | } 95 | 96 | export class AxiosClientAdaptor implements IHttpClientAdaptor { 97 | private client: AxiosInstance; 98 | 99 | constructor( 100 | options: AxiosRequestConfig = {}, 101 | private logger?: Logger, 102 | ) { 103 | this.client = axios.create(options); 104 | } 105 | 106 | public buildRequest(config: RequestConfig) { 107 | const { 108 | headers, 109 | data, 110 | responseType, 111 | responseEncoding, 112 | url, 113 | method, 114 | params, 115 | } = config; 116 | 117 | const axiosConfig: AxiosRequestConfig = { 118 | url, 119 | method, 120 | headers, 121 | data, 122 | params, 123 | responseType, 124 | // never have axios throw and error. Return request. 125 | validateStatus: () => true, 126 | }; 127 | // axios for some reason does not allow the responseEncoding to be set 128 | if (responseEncoding) 129 | (axiosConfig as any).responseEncoding = responseEncoding; 130 | 131 | const request = new AxiosRequest(this.client, config, axiosConfig); 132 | return request; 133 | } 134 | 135 | /** Add header to each HTTP request for this instance */ 136 | public addGlobalApiHeader(header: HttpHeader) { 137 | const headers: Record = this.client.defaults.headers!; // default headers always exist 138 | headers.common[header.name] = header.value; 139 | } 140 | 141 | /** Add headers to each HTTP request for this instance */ 142 | public addGlobalApiHeaders(headers: HttpHeader[]) { 143 | headers.forEach((header) => this.addGlobalApiHeader(header)); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /packages/httpclient-axios/src/normalizeHeaders.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeAxiosHeaders } from './normalizeHeaders'; 2 | 3 | describe('normalizeHeaders', () => { 4 | it('should normalize headers', () => { 5 | const headers = { 6 | 'Content-Type': 'application/json', 7 | Accept: 'application/json', 8 | 'Accept-Encoding': ['gzip', 'deflate'], 9 | 'X-Test': 123, 10 | 'X-Test-Null': null, 11 | }; 12 | 13 | const normalizedHeaders = normalizeAxiosHeaders(headers); 14 | expect(normalizedHeaders).toEqual({ 15 | 'Content-Type': 'application/json', 16 | Accept: 'application/json', 17 | 'Accept-Encoding': 'gzip,deflate', 18 | 'X-Test': '123', 19 | 'X-Test-Null': '', 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/httpclient-axios/src/normalizeHeaders.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHeaders, AxiosResponse } from 'axios'; 2 | 3 | export const normalizeAxiosHeaders = ( 4 | headers: AxiosResponse['headers'], 5 | ): Record => { 6 | const normalizedHeaders: Record = {}; 7 | for (const key in headers) { 8 | const header = headers[key]; 9 | if (typeof header === 'string') { 10 | normalizedHeaders[key] = header; 11 | } else if (Array.isArray(header)) { 12 | normalizedHeaders[key] = header.join(','); 13 | } else if (typeof header === 'number') { 14 | normalizedHeaders[key] = header.toString(); 15 | } else if (header === undefined) { 16 | normalizedHeaders[key] = ''; 17 | } else if (header === null) { 18 | normalizedHeaders[key] = ''; 19 | } else if (header instanceof Blob) { 20 | console.error('Blob not supported'); 21 | } else if (header instanceof ArrayBuffer) { 22 | console.error('ArrayBuffer not supported'); 23 | } else if (header instanceof AxiosHeaders) { 24 | console.error('AxiosHeaders not supported'); 25 | } 26 | } 27 | return normalizedHeaders; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/httpclient-axios/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "composite": true, 6 | "noEmit": false, 7 | "baseUrl": "src", 8 | "rootDir": "src", 9 | "outDir": "dist", 10 | "paths": { 11 | "@seriouslag/*": [ 12 | "../../*" 13 | ] 14 | } 15 | }, 16 | "include": [ 17 | "./src/**/*.ts" 18 | ], 19 | "exclude": [ 20 | "./**/*.test.*", 21 | "dist/**/*", 22 | "node_modules/**/*" 23 | ] 24 | } -------------------------------------------------------------------------------- /packages/httpclient-axios/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "moduleResolution": "Node10", 6 | "declaration": true, 7 | "composite": true, 8 | "target": "ESNext", 9 | "sourceMap": false, 10 | "declarationMap": false 11 | }, 12 | "include": [ 13 | "./src/**/*.ts" 14 | ], 15 | "exclude": [ 16 | "./**/*.test.*", 17 | "node_modules/**/*", 18 | "src/examples/**/*", 19 | "dist/**/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/httpclient-axios/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDir": "src", 6 | "strict": true, 7 | "paths": { 8 | "@seriouslag/*": ["../*/src/index"] 9 | } 10 | }, 11 | "references": [ 12 | { 13 | "path": "../httpclient" 14 | }, 15 | { 16 | "path": "../examples" 17 | } 18 | ], 19 | "include": [ 20 | "./src/**/*.ts" 21 | ], 22 | "exclude": [ 23 | "./**/*.test.*", 24 | "dist/**/*", 25 | "node_modules/**/*" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /packages/httpclient/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 | ## 0.0.17 (2024-04-11) 7 | 8 | **Note:** Version bump only for package @seriouslag/httpclient 9 | -------------------------------------------------------------------------------- /packages/httpclient/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Landon Gavin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/httpclient/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@seriouslag/httpclient", 3 | "version": "0.0.20", 4 | "description": "Typed wrapper HttpClient for axios", 5 | "browser": "dist/index.min.cjs", 6 | "module": "dist/index.esm", 7 | "type": "module", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "import": "./dist/index.esm", 13 | "require": "./dist/index.cjs" 14 | } 15 | }, 16 | "scripts": { 17 | "build": "rollup -c", 18 | "example:basic": "ts-node src/examples/example-basic.ts", 19 | "example:cancelToken": "ts-node src/examples/example-cancelToken.ts" 20 | }, 21 | "contributors": [ 22 | { 23 | "name": "Landon Gavin", 24 | "email": "hi@landongavin.com", 25 | "url": "https://landongavin.dev" 26 | } 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/seriouslag/HttpClient.git" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "keywords": [ 36 | "httpClient", 37 | "typescript", 38 | "axios", 39 | "fetch" 40 | ], 41 | "bugs": { 42 | "url": "https://github.com/seriouslag/HttpClient/issues" 43 | }, 44 | "author": "hi@landongavin.com", 45 | "license": "MIT", 46 | "files": [ 47 | "dist", 48 | "src", 49 | "package.json", 50 | "tsconfig.json", 51 | "tsconfig.build.json", 52 | "tsconfig.base.json", 53 | "README.md", 54 | "LICENSE" 55 | ], 56 | "dependencies": {} 57 | } 58 | -------------------------------------------------------------------------------- /packages/httpclient/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import NodeBuiltins from 'rollup-plugin-polyfill-node'; 3 | import terser from '@rollup/plugin-terser'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 6 | 7 | const typescriptPlugin = typescript({ 8 | tsconfig: 'tsconfig.build.json', 9 | }); 10 | 11 | const nodeBuiltins = NodeBuiltins(); 12 | const commonjsPlugin = commonjs(); 13 | const peerDepsExternalPlugin = peerDepsExternal(); 14 | 15 | export default [ 16 | // CJS 17 | { 18 | input: 'src/index.ts', 19 | output: { 20 | file: 'dist/index.cjs', 21 | format: 'cjs', 22 | name: 'HttpClient', 23 | indent: false, 24 | }, 25 | plugins: [ 26 | peerDepsExternalPlugin, 27 | commonjsPlugin, 28 | typescriptPlugin, 29 | nodeBuiltins, 30 | ], 31 | }, 32 | // CJS minified 33 | { 34 | input: 'src/index.ts', 35 | output: { 36 | file: 'dist/index.min.cjs', 37 | format: 'cjs', 38 | name: 'HttpClient', 39 | indent: false, 40 | }, 41 | plugins: [ 42 | peerDepsExternalPlugin, 43 | commonjsPlugin, 44 | typescriptPlugin, 45 | nodeBuiltins, 46 | terser(), 47 | ], 48 | }, 49 | // ESM 50 | { 51 | input: 'src/index.ts', 52 | output: { 53 | file: 'dist/index.mjs', 54 | format: 'esm', 55 | indent: false, 56 | }, 57 | plugins: [ 58 | peerDepsExternalPlugin, 59 | typescriptPlugin, 60 | nodeBuiltins, 61 | ], 62 | }, 63 | ]; 64 | -------------------------------------------------------------------------------- /packages/httpclient/src/Adaptors/index.ts: -------------------------------------------------------------------------------- 1 | export type Method = 2 | | 'get' 3 | | 'GET' 4 | | 'delete' 5 | | 'DELETE' 6 | | 'head' 7 | | 'HEAD' 8 | | 'options' 9 | | 'OPTIONS' 10 | | 'post' 11 | | 'POST' 12 | | 'put' 13 | | 'PUT' 14 | | 'patch' 15 | | 'PATCH' 16 | | 'purge' 17 | | 'PURGE' 18 | | 'link' 19 | | 'LINK' 20 | | 'unlink' 21 | | 'UNLINK'; 22 | export type ResponseType = 23 | | 'arraybuffer' 24 | | 'blob' 25 | | 'document' 26 | | 'json' 27 | | 'text' 28 | | 'stream'; 29 | 30 | /** Response data from using the fetch request */ 31 | export interface HttpResponse { 32 | /** Response headers */ 33 | headers: Record; 34 | /** Response body */ 35 | data: T; 36 | /** Response status */ 37 | status: number; 38 | /** Response status text */ 39 | statusText: string; 40 | } 41 | 42 | /** Structure of HTTP Header */ 43 | export interface HttpHeader { 44 | /** Header name */ 45 | name: string; 46 | /** Header value */ 47 | value: string; 48 | } 49 | 50 | export type RequestConfig = { 51 | url: string; 52 | method: Method; 53 | /** If specified, a new axios instance is used instead of the one instantiated in the HttpClient's constructor */ 54 | noGlobal?: boolean; 55 | /** The headers that will be used in the HTTP call. Global headers will be added to these. 56 | * // TODO: Test when noGlobal is true if global headers are added to the request 57 | */ 58 | headers?: Record; 59 | /** The body of the request that will be sent */ 60 | data?: any; 61 | /** The type of response that will be expected */ 62 | responseType?: ResponseType; 63 | /** The query parameters that will be sent with the HTTP call */ 64 | params?: any; 65 | /** The encoding of the response */ 66 | responseEncoding?: string; 67 | withCredentials?: boolean; 68 | onUploadProgress?: (progressEvent: any) => void; 69 | onDownloadProgress?: (progressEvent: any) => void; 70 | cancelToken?: AbortController; 71 | }; 72 | 73 | export interface Request { 74 | do: () => Promise>; 75 | } 76 | 77 | export interface IHttpClientAdaptor { 78 | buildRequest: ( 79 | config: RequestConfig, 80 | cancelToken?: AbortController, 81 | ) => Request; 82 | /** Add header to each HTTP request for this instance */ 83 | addGlobalApiHeader: (header: HttpHeader) => void; 84 | 85 | /** Add headers to each HTTP request for this instance */ 86 | addGlobalApiHeaders: (headers: HttpHeader[]) => void; 87 | } 88 | -------------------------------------------------------------------------------- /packages/httpclient/src/FetchClientAdaptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpHeader, 3 | HttpResponse, 4 | IHttpClientAdaptor, 5 | Request, 6 | RequestConfig, 7 | } from './Adaptors'; 8 | import { AbortError } from './errors/AbortError'; 9 | 10 | type FetchRequestConfig = RequestConfig; 11 | 12 | class FetchRequest implements Request { 13 | constructor(private config: FetchRequestConfig) {} 14 | 15 | public async do(): Promise> { 16 | try { 17 | const response = await fetch(this.config.url, { 18 | method: this.config.method, 19 | headers: this.config.headers, 20 | body: this.config.data ? JSON.stringify(this.config.data) : undefined, 21 | signal: this.config.cancelToken?.signal, 22 | }); 23 | const headers: Record< 24 | string, 25 | string | undefined | null | number | boolean 26 | > = {}; 27 | response.headers.forEach((value, key) => { 28 | headers[key] = value; 29 | }); 30 | return { 31 | headers, 32 | data: response.body ? await response.json() : undefined, 33 | status: response.status, 34 | statusText: response.statusText, 35 | } satisfies HttpResponse; 36 | } catch (error) { 37 | // if request is canceled then throw an abort error, keeps the error handling consistent 38 | // TODO: check if request was aborted then throw an AbortError 39 | if (error instanceof DOMException && error.name === 'AbortError') { 40 | throw new AbortError(error.message, { 41 | cause: error, 42 | }); 43 | } 44 | throw error; 45 | } 46 | } 47 | } 48 | 49 | export class FetchClientAdaptor implements IHttpClientAdaptor { 50 | private globalHeaders: Map = new Map(); 51 | 52 | public buildRequest(config: RequestConfig): Request { 53 | return new FetchRequest(config); 54 | } 55 | 56 | public addGlobalApiHeader(header: HttpHeader): void { 57 | this.globalHeaders.set(header.name, header.value); 58 | } 59 | 60 | public addGlobalApiHeaders(headers: HttpHeader[]): void { 61 | headers.forEach((header) => this.addGlobalApiHeader(header)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpClient.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpResponse, 3 | HttpClient, 4 | AbortError, 5 | LogFunction, 6 | Logger, 7 | DefaultHttpRequestStrategy, 8 | } from './index'; 9 | import { mocked } from 'jest-mock'; 10 | import { ABORT_MESSAGE, ERROR_URL } from './strings'; 11 | import { 12 | HttpRequestStrategy, 13 | MaxRetryHttpRequestStrategy, 14 | } from './HttpRequestStrategies'; 15 | import { Request } from './Adaptors'; 16 | import { FetchClientAdaptor } from './FetchClientAdaptor'; 17 | 18 | import 'jest-fetch-mock'; 19 | import { Sleep } from './utilities/sleep'; 20 | 21 | const logger: Logger = { 22 | debug: jest.fn() as LogFunction, 23 | info: jest.fn() as LogFunction, 24 | warn: jest.fn() as LogFunction, 25 | error: jest.fn() as LogFunction, 26 | }; 27 | 28 | const mockedLogger = mocked(logger); 29 | 30 | const responseData: Partial> = { 31 | data: 'data', 32 | status: 200, 33 | headers: { 34 | 'content-type': 'text/plain;charset=UTF-8', 35 | }, 36 | statusText: 'OK', 37 | }; 38 | 39 | const buildMockOnce = (body: any | undefined, status: number) => () => 40 | fetchMock.mockResponseOnce(body ? JSON.stringify(body) : '', { 41 | status, 42 | }); 43 | 44 | const mockOnceSuccess = buildMockOnce(responseData.data, 200); 45 | 46 | const mockOnceFailure = () => 47 | fetchMock.mockRejectOnce(new Error('TESTING ERROR')); 48 | 49 | const mockSuccessDelay = (timeout: number = 100) => 50 | fetchMock.mockResponseOnce(async () => { 51 | await Sleep(timeout); 52 | return { 53 | body: JSON.stringify(responseData.data), 54 | status: 200, 55 | }; 56 | }); 57 | 58 | describe('HttpClient', () => { 59 | beforeEach(() => { 60 | jest.resetModules(); 61 | jest.resetAllMocks(); 62 | fetchMock.resetMocks(); 63 | }); 64 | 65 | it('constructs', () => { 66 | const httpClientAdaptor = new FetchClientAdaptor(); 67 | const httpClient = new HttpClient(httpClientAdaptor); 68 | expect.assertions(1); 69 | expect(httpClient).toBeInstanceOf(HttpClient); 70 | }); 71 | 72 | it('fetch - success', async () => { 73 | mockOnceSuccess(); 74 | const httpClientAdaptor = new FetchClientAdaptor(); 75 | const httpClient = new HttpClient(httpClientAdaptor); 76 | const result = await httpClient.request('www.google.com', 'GET'); 77 | expect.assertions(1); 78 | expect(result).toEqual(responseData); 79 | }); 80 | 81 | it.skip('fetch - handles noGlobal option', async () => { 82 | const request = jest.fn((_config: any) => Promise.resolve(responseData)); 83 | const httpClientAdaptor = new FetchClientAdaptor(); 84 | const create = jest.fn().mockImplementation(() => { 85 | return { request }; 86 | }); 87 | mockOnceSuccess(); 88 | const httpClient = new HttpClient(httpClientAdaptor); 89 | await httpClient.request('www.google.com', 'GET', { 90 | noGlobal: true, 91 | }); 92 | expect.assertions(1); 93 | expect(create).toHaveBeenCalledTimes(1); 94 | }); 95 | 96 | it('fetch - throws if no url is provided - undefined', async () => { 97 | const httpClientAdaptor = new FetchClientAdaptor(); 98 | const httpClient = new HttpClient(httpClientAdaptor); 99 | expect.assertions(1); 100 | await expect(httpClient.request(undefined as any, 'GET')).rejects.toThrow( 101 | ERROR_URL, 102 | ); 103 | }); 104 | 105 | it('fetch - throws if no url is provided - null', async () => { 106 | const httpClientAdaptor = new FetchClientAdaptor(); 107 | const httpClient = new HttpClient(httpClientAdaptor); 108 | expect.assertions(1); 109 | await expect(httpClient.request(null as any, 'GET')).rejects.toThrow( 110 | ERROR_URL, 111 | ); 112 | }); 113 | 114 | it('fetch - throws if no url is provided - number', async () => { 115 | const httpClientAdaptor = new FetchClientAdaptor(); 116 | const httpClient = new HttpClient(httpClientAdaptor); 117 | expect.assertions(1); 118 | await expect(httpClient.request(1 as any, 'GET')).rejects.toThrow( 119 | ERROR_URL, 120 | ); 121 | }); 122 | 123 | it('fetch - throws if no url is provided - object', async () => { 124 | const httpClientAdaptor = new FetchClientAdaptor(); 125 | const httpClient = new HttpClient(httpClientAdaptor); 126 | expect.assertions(1); 127 | await expect(httpClient.request({} as any, 'GET')).rejects.toThrow( 128 | ERROR_URL, 129 | ); 130 | }); 131 | 132 | it('get', async () => { 133 | mockOnceSuccess(); 134 | const httpClientAdaptor = new FetchClientAdaptor(); 135 | const httpClient = new HttpClient(httpClientAdaptor); 136 | const result = await httpClient.get('www.google.com'); 137 | 138 | expect.assertions(1); 139 | expect(result).toEqual(responseData.data); 140 | }); 141 | 142 | it('post', async () => { 143 | mockOnceSuccess(); 144 | const httpClientAdaptor = new FetchClientAdaptor(); 145 | const httpClient = new HttpClient(httpClientAdaptor); 146 | const result = await httpClient.post('www.google.com'); 147 | 148 | expect.assertions(1); 149 | expect(result).toEqual(responseData.data); 150 | }); 151 | 152 | it('delete', async () => { 153 | mockOnceSuccess(); 154 | const httpClientAdaptor = new FetchClientAdaptor(); 155 | const httpClient = new HttpClient(httpClientAdaptor); 156 | const result = await httpClient.delete('www.google.com'); 157 | 158 | expect.assertions(1); 159 | expect(result).toEqual(responseData.data); 160 | }); 161 | 162 | it('patch', async () => { 163 | mockOnceSuccess(); 164 | const httpClientAdaptor = new FetchClientAdaptor(); 165 | const httpClient = new HttpClient(httpClientAdaptor); 166 | const result = await httpClient.patch('www.google.com'); 167 | 168 | expect.assertions(1); 169 | expect(result).toEqual(responseData.data); 170 | }); 171 | 172 | it('put', async () => { 173 | mockOnceSuccess(); 174 | const httpClientAdaptor = new FetchClientAdaptor(); 175 | const httpClient = new HttpClient(httpClientAdaptor); 176 | const result = await httpClient.put('www.google.com'); 177 | 178 | expect.assertions(1); 179 | expect(result).toEqual(responseData.data); 180 | }); 181 | 182 | it('logger is not set when HttpClient is constructed', () => { 183 | const httpClientAdaptor = new FetchClientAdaptor(); 184 | const httpClient = new HttpClient(httpClientAdaptor); 185 | expect.assertions(1); 186 | expect((httpClient as any).logger).toBeUndefined(); 187 | }); 188 | 189 | it('setLogger sets logger', () => { 190 | const httpClientAdaptor = new FetchClientAdaptor(); 191 | const httpClient = new HttpClient(httpClientAdaptor); 192 | 193 | expect.assertions(1); 194 | httpClient.setLogger(mockedLogger); 195 | expect((httpClient as any).logger).toEqual(mockedLogger); 196 | }); 197 | 198 | it('fetch - if token is already aborted then axios call is aborted', async () => { 199 | const url = 'www.google.com'; 200 | const method = 'get'; 201 | const cancelToken = new AbortController(); 202 | const httpClientAdaptor = new FetchClientAdaptor(); 203 | const httpClient = new HttpClient(httpClientAdaptor); 204 | cancelToken.abort(); 205 | expect.assertions(1); 206 | mockOnceSuccess(); 207 | await expect( 208 | httpClient.request(url, method, {}, cancelToken), 209 | ).rejects.toThrow(); 210 | }); 211 | 212 | it('fetch - if token is already aborted then error is an AbortError', async () => { 213 | const url = 'www.google.com'; 214 | const method = 'get'; 215 | const cancelToken = new AbortController(); 216 | const httpClientAdaptor = new FetchClientAdaptor(); 217 | const httpClient = new HttpClient(httpClientAdaptor); 218 | 219 | cancelToken.abort(); 220 | 221 | mockOnceSuccess(); 222 | 223 | expect.assertions(2); 224 | try { 225 | await httpClient.request(url, method, {}, cancelToken); 226 | } catch (e) { 227 | const error = e as AbortError; 228 | expect(error).toBeInstanceOf(AbortError); 229 | expect(error.message).toEqual(ABORT_MESSAGE); 230 | } 231 | }); 232 | 233 | it('fetch - if token is aborted after axios call, axios call is aborted', async () => { 234 | const url = 'www.google.com'; 235 | const method = 'get'; 236 | const cancelToken = new AbortController(); 237 | const httpClientAdaptor = new FetchClientAdaptor(); 238 | const httpClient = new HttpClient(httpClientAdaptor); 239 | 240 | mockSuccessDelay(2000); 241 | 242 | const promise = httpClient.request(url, method, {}, cancelToken); 243 | cancelToken.abort(); 244 | 245 | expect.assertions(1); 246 | await expect(() => promise).rejects.toThrow(); 247 | }); 248 | 249 | it('fetch - if token is aborted after axios call, AbortError is throw', async () => { 250 | const url = 'www.google.com'; 251 | const method = 'get'; 252 | const cancelToken = new AbortController(); 253 | const httpClientAdaptor = new FetchClientAdaptor(); 254 | const httpClient = new HttpClient(httpClientAdaptor); 255 | 256 | mockSuccessDelay(); 257 | const promise = httpClient.request(url, method, {}, cancelToken); 258 | cancelToken.abort(); 259 | expect.assertions(1); 260 | await expect(() => promise).rejects.toThrow(AbortError); 261 | }); 262 | 263 | it('fetch - if token is aborted after axios call is complete, axios call is completed', async () => { 264 | const url = 'www.google.com'; 265 | const method = 'get'; 266 | const httpClientAdaptor = new FetchClientAdaptor(); 267 | const httpClient = new HttpClient(httpClientAdaptor); 268 | const cancelToken = new AbortController(); 269 | mockOnceSuccess(); 270 | 271 | const promise = httpClient.request(url, method, {}, cancelToken); 272 | await promise; 273 | cancelToken.abort(); 274 | 275 | expect.assertions(1); 276 | const result = await promise; 277 | expect(result).toEqual(responseData); 278 | }); 279 | 280 | it('fetch - rejected request, fails', async () => { 281 | mockOnceFailure(); 282 | 283 | const httpClientAdaptor = new FetchClientAdaptor(); 284 | const httpClient = new HttpClient(httpClientAdaptor); 285 | const abort = jest.fn((_message?: string) => {}); 286 | const cancelToken = new AbortController(); 287 | const url = 'www.google.com'; 288 | const method = 'get'; 289 | 290 | cancelToken.abort = abort; 291 | expect.assertions(2); 292 | await expect(httpClient.request(url, method, {})).rejects.toThrow( 293 | 'TESTING ERROR', 294 | ); 295 | expect(abort).not.toHaveBeenCalled(); 296 | }); 297 | 298 | it('fetch - rejected request, fails, cancels token', async () => { 299 | mockOnceFailure(); 300 | 301 | const httpClientAdaptor = new FetchClientAdaptor(); 302 | const httpClient = new HttpClient(httpClientAdaptor); 303 | const abort = jest.fn((_message?: string) => {}); 304 | const cancelToken = new AbortController(); 305 | const url = 'www.google.com'; 306 | const method = 'get'; 307 | 308 | cancelToken.abort = abort; 309 | expect.assertions(2); 310 | await expect( 311 | httpClient.request(url, method, {}, cancelToken), 312 | ).rejects.toThrow('TESTING ERROR'); 313 | expect(abort).toHaveBeenCalledTimes(1); 314 | }); 315 | 316 | it('Adds global headers', async () => { 317 | const httpClientAdaptor = new FetchClientAdaptor(); 318 | httpClientAdaptor.addGlobalApiHeaders([ 319 | { 320 | name: 'Name1', 321 | value: 'Value1', 322 | }, 323 | { 324 | name: 'Name2', 325 | value: 'Value2', 326 | }, 327 | ]); 328 | const globalHeaders = httpClientAdaptor['globalHeaders']; 329 | expect(globalHeaders.get('Name1')).toEqual('Value1'); 330 | expect(globalHeaders.get('Name2')).toEqual('Value2'); 331 | }); 332 | 333 | it('api - throws error response when status is invalid', async () => { 334 | mockOnceFailure(); 335 | 336 | const httpClientAdaptor = new FetchClientAdaptor(); 337 | const httpClient = new HttpClient(httpClientAdaptor); 338 | expect.assertions(1); 339 | expect(() => 340 | httpClient.dataRequest('www.google.com', 'GET'), 341 | ).rejects.toThrow(); 342 | }); 343 | 344 | it('api - when status is invalid cancel token is aborted', async () => { 345 | mockOnceFailure(); 346 | 347 | const httpClientAdaptor = new FetchClientAdaptor(); 348 | const httpClient = new HttpClient(httpClientAdaptor); 349 | expect.assertions(1); 350 | 351 | const abort = jest.fn((_message?: string) => {}); 352 | const cancelToken = new AbortController(); 353 | 354 | cancelToken.abort = abort; 355 | 356 | try { 357 | await httpClient.dataRequest('www.google.com', 'GET', {}, cancelToken); 358 | } catch (e) { 359 | expect(abort).toHaveBeenCalledTimes(1); 360 | } 361 | }); 362 | 363 | it('fetch - logger is called before request is made', async () => { 364 | mockOnceSuccess(); 365 | const httpClientAdaptor = new FetchClientAdaptor(); 366 | const httpClient = new HttpClient(httpClientAdaptor); 367 | httpClient.setLogger(logger); 368 | expect.assertions(1); 369 | 370 | const promise = httpClient.request('www.google.com', 'GET'); 371 | expect(logger.debug).toHaveBeenCalledTimes(1); 372 | await promise; 373 | }); 374 | 375 | it('fetch - logger is called before request is made', async () => { 376 | mockOnceSuccess(); 377 | const httpClientAdaptor = new FetchClientAdaptor(); 378 | const httpClient = new HttpClient(httpClientAdaptor); 379 | httpClient.setLogger(logger); 380 | expect.assertions(1); 381 | 382 | await httpClient.request('www.google.com', 'GET'); 383 | expect(logger.debug).toHaveBeenCalledTimes(2); 384 | }); 385 | 386 | it('fetch - logger is called on error', async () => { 387 | mockOnceFailure(); 388 | const httpClientAdaptor = new FetchClientAdaptor(); 389 | const httpClient = new HttpClient(httpClientAdaptor); 390 | httpClient.setLogger(logger); 391 | expect.assertions(1); 392 | try { 393 | await httpClient.request('www.google.com', 'GET'); 394 | } catch { 395 | expect(logger.error).toHaveBeenCalledTimes(1); 396 | } 397 | }); 398 | 399 | it('httpRequestStrategy - uses default if no request is passed in', () => { 400 | const httpClientAdaptor = new FetchClientAdaptor(); 401 | const httpClient = new HttpClient(httpClientAdaptor); 402 | expect((httpClient as any).httpRequestStrategy).toBeInstanceOf( 403 | DefaultHttpRequestStrategy, 404 | ); 405 | }); 406 | 407 | it('httpRequestStrategy - uses strategy passed in constructor', () => { 408 | const strategy = new MaxRetryHttpRequestStrategy(); 409 | const httpClientAdaptor = new FetchClientAdaptor(); 410 | const httpClient = new HttpClient(httpClientAdaptor, { 411 | httpRequestStrategy: strategy, 412 | }); 413 | expect((httpClient as any).httpRequestStrategy).toBeInstanceOf( 414 | MaxRetryHttpRequestStrategy, 415 | ); 416 | }); 417 | 418 | it('httpRequestStrategy - uses strategy passed in request over one provided by HttpClient', async () => { 419 | expect.assertions(2); 420 | let httpClientStrategyCount = 0; 421 | let requestStrategyCount = 0; 422 | 423 | mockOnceSuccess(); 424 | 425 | const httpClientStrategy: HttpRequestStrategy = { 426 | request: async (request: Request) => { 427 | httpClientStrategyCount += 1; 428 | const response = await request.do(); 429 | return response; 430 | }, 431 | }; 432 | 433 | const requestStrategy: HttpRequestStrategy = { 434 | request: async (request: Request) => { 435 | requestStrategyCount += 1; 436 | const response = await request.do(); 437 | return response; 438 | }, 439 | }; 440 | 441 | const httpClientAdaptor = new FetchClientAdaptor(); 442 | const httpClient = new HttpClient(httpClientAdaptor, { 443 | httpRequestStrategy: httpClientStrategy, 444 | }); 445 | 446 | await httpClient.get('', { 447 | httpRequestStrategy: requestStrategy, 448 | }); 449 | 450 | expect(httpClientStrategyCount).toEqual(0); 451 | expect(requestStrategyCount).toEqual(1); 452 | }); 453 | }); 454 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IHttpClientAdaptor, 3 | Method, 4 | RequestConfig, 5 | ResponseType, 6 | HttpResponse, 7 | } from './Adaptors'; 8 | import { FetchClientAdaptor } from './FetchClientAdaptor'; 9 | import { 10 | HttpRequestStrategy, 11 | DefaultHttpRequestStrategy, 12 | } from './HttpRequestStrategies'; 13 | import { Logger } from './Logger'; 14 | import { AbortError } from './errors/AbortError'; 15 | import { ABORT_MESSAGE, ERROR_URL } from './strings'; 16 | 17 | /** Config used for setting up http calls */ 18 | export interface ApiConfig { 19 | /** If specified, a new axios instance is used instead of the one instantiated in the HttpClient's constructor */ 20 | noGlobal?: boolean; 21 | /** The headers that will be used in the HTTP call. Global headers will be added to these. 22 | * // TODO: Test when noGlobal is true if global headers are added to the request 23 | */ 24 | headers?: Record; 25 | /** The body of the request that will be sent */ 26 | data?: any; 27 | /** The type of response that will be expected */ 28 | responseType?: ResponseType; 29 | /** The query parameters that will be sent with the HTTP call */ 30 | params?: any; 31 | /** The encoding of the response */ 32 | responseEncoding?: string; 33 | /** The strategy to use for this request, if not provided then the request that was provided with the HttpClient will be used */ 34 | httpRequestStrategy?: HttpRequestStrategy; 35 | } 36 | 37 | /** 38 | * HttpClient configuration options 39 | */ 40 | export interface HttpClientOptions { 41 | /** The strategy that will be used to handle http requests */ 42 | httpRequestStrategy?: HttpRequestStrategy; 43 | /** The logger the HttpClient will use */ 44 | logger?: Logger; 45 | baseUrl?: string; 46 | } 47 | 48 | /** Typed wrapper around axios that standardizes making HTTP calls and handling responses */ 49 | export class HttpClient { 50 | private logger: Logger | undefined; 51 | private httpRequestStrategy: HttpRequestStrategy; 52 | private baseUrl: string; 53 | 54 | constructor( 55 | private httpClientAdaptor: IHttpClientAdaptor = new FetchClientAdaptor(), 56 | options: HttpClientOptions = {}, 57 | ) { 58 | const { httpRequestStrategy, logger, baseUrl = '' } = options; 59 | this.httpRequestStrategy = 60 | httpRequestStrategy ?? new DefaultHttpRequestStrategy(); 61 | this.logger = logger; 62 | this.baseUrl = baseUrl; 63 | } 64 | 65 | /** 66 | * Sets the logger for the instance 67 | * @param {Logger|undefined} logger 68 | */ 69 | public setLogger(logger: Logger | undefined) { 70 | this.logger = logger; 71 | } 72 | 73 | /** HTTP GET request */ 74 | public get( 75 | url: string, 76 | config: ApiConfig = {}, 77 | cancelToken?: AbortController, 78 | ): Promise { 79 | const method: Method = 'get'; 80 | return this.dataRequest(url, method, config, cancelToken); 81 | } 82 | 83 | /** HTTP POST request */ 84 | public post( 85 | url: string, 86 | config: ApiConfig = {}, 87 | cancelToken?: AbortController, 88 | ): Promise { 89 | const method: Method = 'post'; 90 | return this.dataRequest(url, method, config, cancelToken); 91 | } 92 | 93 | /** HTTP PUT request */ 94 | public put( 95 | url: string, 96 | config: ApiConfig = {}, 97 | cancelToken?: AbortController, 98 | ): Promise { 99 | const method: Method = 'put'; 100 | return this.dataRequest(url, method, config, cancelToken); 101 | } 102 | 103 | /** HTTP DELETE request */ 104 | public delete( 105 | url: string, 106 | config: ApiConfig = {}, 107 | cancelToken?: AbortController, 108 | ): Promise { 109 | const method: Method = 'delete'; 110 | return this.dataRequest(url, method, config, cancelToken); 111 | } 112 | 113 | /** HTTP PATCH request */ 114 | public patch( 115 | url: string, 116 | config: ApiConfig = {}, 117 | cancelToken?: AbortController, 118 | ): Promise { 119 | const method: Method = 'patch'; 120 | return this.dataRequest(url, method, config, cancelToken); 121 | } 122 | 123 | /** 124 | * HTTP request that returns the body of the HTTP response 125 | * 126 | * If a cancel token is passed in it will be aborted on request error. 127 | * 128 | * @returns {Promise} body of the HTTP response 129 | */ 130 | public async dataRequest( 131 | url: string, 132 | method: Method, 133 | config: ApiConfig = {}, 134 | cancelToken?: AbortController, 135 | ): Promise { 136 | const response = await this.request(url, method, config, cancelToken); 137 | return response.data; 138 | } 139 | 140 | /** 141 | * HTTP request 142 | * 143 | * If a cancel token is passed in it will be aborted on request error. 144 | * 145 | * @returns {Promise>} HttpResponse 146 | */ 147 | public async request( 148 | url: string, 149 | method: Method, 150 | config: ApiConfig = {}, 151 | cancelToken?: AbortController, 152 | ): Promise> { 153 | if (cancelToken?.signal.aborted) { 154 | throw new AbortError(ABORT_MESSAGE); 155 | } 156 | try { 157 | return await this.doRequest(url, method, config, cancelToken); 158 | } catch (e) { 159 | let message = `The ${method} request to ${url} failed`; 160 | if (e && typeof e === 'object' && 'status' in e) { 161 | message += ` with status ${e.status}`; 162 | } 163 | cancelToken?.abort(message); 164 | throw e; 165 | } 166 | } 167 | 168 | private async doRequest( 169 | url: string, 170 | method: Method, 171 | config: ApiConfig = {}, 172 | cancelToken?: AbortController, 173 | ): Promise> { 174 | if (typeof url !== 'string') throw new Error(ERROR_URL); 175 | const { 176 | headers, 177 | data, 178 | params, 179 | responseEncoding, 180 | responseType, 181 | httpRequestStrategy, 182 | noGlobal, 183 | } = config; 184 | 185 | const strategyToUse = httpRequestStrategy ?? this.httpRequestStrategy; 186 | 187 | const requestConfig: RequestConfig = { 188 | url: this.baseUrl + url, 189 | method, 190 | headers, 191 | data, 192 | params, 193 | responseEncoding, 194 | responseType, 195 | cancelToken, 196 | noGlobal, 197 | }; 198 | 199 | try { 200 | const request = this.httpClientAdaptor.buildRequest(requestConfig); 201 | this.logger?.debug(`HTTP - method: ${method}; url: ${url}`); 202 | const response = await strategyToUse.request(request); 203 | this.logger?.debug( 204 | `HTTP ${response.status} - method: ${method}; url: ${url}`, 205 | ); 206 | return response; 207 | } catch (e) { 208 | this.logger?.error(`HTTP error - method: ${method}; url: ${url}`, e); 209 | throw e; 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/DefaultHttpRequestStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { DefaultHttpRequestStrategy, HttpResponse } from '../index'; 2 | import { Request } from '../Adaptors'; 3 | 4 | const successfulResponseData: HttpResponse = { 5 | data: 'data', 6 | status: 200, 7 | headers: {}, 8 | statusText: 'success', 9 | }; 10 | 11 | const failedResponseData: HttpResponse = { 12 | status: 400, 13 | headers: {}, 14 | data: undefined, 15 | statusText: 'bad model', 16 | }; 17 | 18 | describe('DefaultHttpRequestStrategy', () => { 19 | beforeEach(() => { 20 | jest.resetModules(); 21 | jest.resetAllMocks(); 22 | }); 23 | 24 | it('be defined', () => { 25 | expect(new DefaultHttpRequestStrategy()).toBeDefined(); 26 | }); 27 | 28 | it('request - successful', async () => { 29 | expect.assertions(2); 30 | const strategy = new DefaultHttpRequestStrategy(); 31 | 32 | const doFn = jest.fn(() => Promise.resolve(successfulResponseData)); 33 | const request: Request = { 34 | do: doFn, 35 | }; 36 | 37 | const response = await strategy.request(request); 38 | 39 | expect(successfulResponseData.data).toEqual(response.data); 40 | expect(doFn).toHaveBeenCalledTimes(1); 41 | }); 42 | 43 | it('request - error - throws', async () => { 44 | expect.assertions(2); 45 | const strategy = new DefaultHttpRequestStrategy(); 46 | 47 | const doFn = jest.fn(() => Promise.resolve(failedResponseData)); 48 | const request: Request = { 49 | do: doFn, 50 | }; 51 | 52 | await expect(() => strategy.request(request)).rejects.toEqual( 53 | failedResponseData, 54 | ); 55 | expect(doFn).toHaveBeenCalledTimes(1); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/DefaultHttpRequestStrategy.ts: -------------------------------------------------------------------------------- 1 | import { Request, HttpResponse } from '../Adaptors'; 2 | import { getIsSuccessfulHttpStatus } from '../utilities/getIsSuccessfulHttpStatus'; 3 | import { HttpRequestStrategy } from './HttpRequestStrategy'; 4 | 5 | /** The default HTTP request strategy. No logic. */ 6 | export class DefaultHttpRequestStrategy implements HttpRequestStrategy { 7 | /** Passthrough request to axios and check response is successful */ 8 | public async request(request: Request) { 9 | const response = await request.do(); 10 | this.checkResponseStatus(response); 11 | return response; 12 | } 13 | 14 | /** Validates the HTTP response is successful or throws an error */ 15 | private checkResponseStatus( 16 | response: HttpResponse, 17 | ): HttpResponse { 18 | const isSuccessful = getIsSuccessfulHttpStatus(response.status); 19 | if (isSuccessful) { 20 | return response; 21 | } 22 | throw response; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/ExponentialBackoffRequestStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExponentialBackoffOptions, 3 | ExponentialBackoffRequestStrategy, 4 | HttpResponse, 5 | } from '../index'; 6 | import { Request } from '../Adaptors'; 7 | 8 | const successfulResponseData: HttpResponse = { 9 | data: 'data', 10 | status: 200, 11 | headers: {}, 12 | statusText: 'success', 13 | }; 14 | 15 | const failedResponseData: HttpResponse = { 16 | data: undefined, 17 | status: 400, 18 | headers: {}, 19 | statusText: 'bad model', 20 | }; 21 | 22 | const tooManyRequestsResponseData: HttpResponse = { 23 | data: undefined, 24 | status: 429, 25 | headers: {}, 26 | statusText: 'too many requests', 27 | }; 28 | 29 | describe('ExponentialBackoffRequestStrategy', () => { 30 | beforeEach(() => { 31 | jest.resetModules(); 32 | jest.resetAllMocks(); 33 | }); 34 | 35 | it('be defined', () => { 36 | expect(new ExponentialBackoffRequestStrategy()).toBeDefined(); 37 | }); 38 | 39 | it('default maxRetryCount', () => { 40 | const strategy = new ExponentialBackoffRequestStrategy(); 41 | expect((strategy as any).maxRetryCount).toEqual(5); 42 | }); 43 | 44 | it('default delayFirstRequest', () => { 45 | const strategy = new ExponentialBackoffRequestStrategy(); 46 | expect((strategy as any).delayFirstRequest).toEqual(false); 47 | }); 48 | 49 | it('default baseDelay', () => { 50 | const strategy = new ExponentialBackoffRequestStrategy(); 51 | expect((strategy as any).baseDelay).toEqual(100); 52 | }); 53 | 54 | it('default factor', () => { 55 | const strategy = new ExponentialBackoffRequestStrategy(); 56 | expect((strategy as any).factor).toEqual(2); 57 | }); 58 | 59 | it('default maxDelay', () => { 60 | const strategy = new ExponentialBackoffRequestStrategy(); 61 | expect((strategy as any).maxDelay).toEqual(-1); 62 | }); 63 | 64 | it('accept options', () => { 65 | const maxRetryCount = 10; 66 | const delayFirstRequest = true; 67 | const baseDelay = 10; 68 | const factor = 3; 69 | const maxDelay = 100; 70 | const options: ExponentialBackoffOptions = { 71 | maxRetryCount, 72 | delayFirstRequest, 73 | baseDelay, 74 | factor, 75 | maxDelay, 76 | }; 77 | const strategy = new ExponentialBackoffRequestStrategy(options); 78 | expect((strategy as any).maxRetryCount).toEqual(maxRetryCount); 79 | expect((strategy as any).delayFirstRequest).toEqual(delayFirstRequest); 80 | expect((strategy as any).baseDelay).toEqual(baseDelay); 81 | expect((strategy as any).factor).toEqual(factor); 82 | expect((strategy as any).maxDelay).toEqual(maxDelay); 83 | }); 84 | 85 | it('request once on a success response', async () => { 86 | expect.assertions(2); 87 | const strategy = new ExponentialBackoffRequestStrategy(); 88 | const doFn = jest.fn(() => Promise.resolve(successfulResponseData)); 89 | const request: Request = { 90 | do: doFn, 91 | }; 92 | 93 | const response = await strategy.request(request); 94 | 95 | expect(successfulResponseData.data).toEqual(response.data); 96 | expect(doFn).toHaveBeenCalledTimes(1); 97 | }); 98 | 99 | it('request until successful, 1 failed, 1 success, 5 max', async () => { 100 | expect.assertions(2); 101 | const strategy = new ExponentialBackoffRequestStrategy({ 102 | maxRetryCount: 5, 103 | factor: 0, 104 | baseDelay: 0, 105 | }); 106 | 107 | let requestCount = 0; 108 | 109 | const doFn = jest.fn(() => { 110 | if (requestCount === 0) { 111 | requestCount += 1; 112 | return Promise.resolve(failedResponseData); 113 | } 114 | requestCount += 1; 115 | return Promise.resolve(successfulResponseData); 116 | }); 117 | const request: Request = { 118 | do: doFn, 119 | }; 120 | 121 | const response = await strategy.request(request); 122 | 123 | expect(response.data).toEqual(successfulResponseData.data); 124 | expect(doFn).toHaveBeenCalledTimes(2); 125 | }); 126 | 127 | it('request until maxRetryCount, 10 failed, 0 success, 10 max', async () => { 128 | expect.assertions(2); 129 | const maxRetryCount = 10; 130 | const strategy = new ExponentialBackoffRequestStrategy({ 131 | maxRetryCount, 132 | factor: 0, 133 | baseDelay: 0, 134 | }); 135 | 136 | const doFn = jest.fn(() => { 137 | return Promise.resolve(failedResponseData); 138 | }); 139 | 140 | const request: Request = { 141 | do: doFn, 142 | }; 143 | 144 | const response = await strategy.request(request); 145 | 146 | expect(response.data).toEqual(failedResponseData.data); 147 | expect(doFn).toHaveBeenCalledTimes(maxRetryCount); 148 | }); 149 | 150 | it('request until hits TOO_MANY_REQUESTS_STATUS, 3 failed, 1 TOO_MANY..., 5 max', async () => { 151 | expect.assertions(2); 152 | const strategy = new ExponentialBackoffRequestStrategy({ 153 | maxRetryCount: 5, 154 | factor: 0, 155 | baseDelay: 0, 156 | }); 157 | 158 | let requestCount = 0; 159 | 160 | const doFn = jest.fn(() => { 161 | if (requestCount === 3) { 162 | requestCount += 1; 163 | return Promise.resolve(tooManyRequestsResponseData); 164 | } 165 | requestCount += 1; 166 | return Promise.resolve(failedResponseData); 167 | }); 168 | 169 | const request: Request = { 170 | do: doFn, 171 | }; 172 | 173 | const response = await strategy.request(request); 174 | 175 | expect(response.data).toEqual(tooManyRequestsResponseData.data); 176 | expect(doFn).toHaveBeenCalledTimes(4); 177 | }); 178 | 179 | it('request forever if a zero is passed for maxRetryCount, 99 failed, 1 success..., 0 max', async () => { 180 | expect.assertions(2); 181 | const maxRetryCount = 0; 182 | const strategy = new ExponentialBackoffRequestStrategy({ 183 | maxRetryCount, 184 | factor: 0, 185 | baseDelay: 0, 186 | }); 187 | 188 | let requestCount = 0; 189 | 190 | const doFn = jest.fn(() => { 191 | if (requestCount === 99) { 192 | requestCount += 1; 193 | return Promise.resolve(successfulResponseData); 194 | } 195 | requestCount += 1; 196 | return Promise.resolve(failedResponseData); 197 | }); 198 | 199 | const request: Request = { 200 | do: doFn, 201 | }; 202 | 203 | const response = await strategy.request(request); 204 | 205 | expect(response.data).toEqual(successfulResponseData.data); 206 | expect(doFn).toHaveBeenCalledTimes(100); 207 | }); 208 | 209 | it('first request is delayed with baseDelay if delayFirstRequest is passed', async () => { 210 | expect.assertions(2); 211 | const delayFirstRequest = true; 212 | const baseDelay = 1000; 213 | const strategy = new ExponentialBackoffRequestStrategy({ 214 | delayFirstRequest, 215 | baseDelay, 216 | }); 217 | const doFn = jest.fn(() => Promise.resolve(successfulResponseData)); 218 | 219 | const request: Request = { 220 | do: doFn, 221 | }; 222 | 223 | const then = Date.now(); 224 | await strategy.request(request); 225 | const now = Date.now(); 226 | 227 | expect(now).toBeGreaterThan(then); 228 | expect(now - then).toBeGreaterThanOrEqual(baseDelay); 229 | }); 230 | 231 | it('request will backoff based on baseDelay and factor, 3 failed, 1 success..., 5 max', async () => { 232 | expect.assertions(2); 233 | const delayFirstRequest = false; 234 | const baseDelay = 100; 235 | const factor = 1.25; 236 | const maxRetryCount = 5; 237 | const strategy = new ExponentialBackoffRequestStrategy({ 238 | delayFirstRequest, 239 | baseDelay, 240 | factor, 241 | maxRetryCount, 242 | }); 243 | 244 | let requestCount = 0; 245 | 246 | const doFn = jest.fn(() => { 247 | if (requestCount === 3) { 248 | requestCount += 1; 249 | return Promise.resolve(successfulResponseData); 250 | } 251 | requestCount += 1; 252 | return Promise.resolve(failedResponseData); 253 | }); 254 | const request: Request = { 255 | do: doFn, 256 | }; 257 | 258 | /** 259 | * 1st request: 0s 260 | * 2nd request: 100ms - baseDelay 100 261 | * 3rd request: 125ms - baseDelay 100 * factor 1.25 * retryCount 1 262 | * 4th request: 250ms - baseDelay 100 * factor 1.25 * retryCount 2 263 | * total: 475ms 264 | */ 265 | 266 | const then = Date.now(); 267 | await strategy.request(request); 268 | const now = Date.now(); 269 | 270 | expect(now).toBeGreaterThan(then); 271 | 272 | const firstRequestTime = 0; 273 | const secondRequestTime = baseDelay; 274 | const thirdRequestTime = baseDelay * factor * 1; 275 | const forthRequestTime = baseDelay * factor * 2; 276 | // buffer is used to variance of tests 277 | const bufferTime = 25; 278 | 279 | const totalTime = 280 | firstRequestTime + 281 | secondRequestTime + 282 | thirdRequestTime + 283 | forthRequestTime + 284 | bufferTime; 285 | 286 | expect(now - then).toBeGreaterThanOrEqual(totalTime); 287 | }); 288 | 289 | it('request will backoff based on maxDelay if maxDelay is lower than the retry algorithm, 3 failed, 1 success..., 5 max', async () => { 290 | expect.assertions(2); 291 | const delayFirstRequest = false; 292 | const baseDelay = 100; 293 | const factor = 1.25; 294 | const maxRetryCount = 5; 295 | const maxDelay = 10; 296 | const strategy = new ExponentialBackoffRequestStrategy({ 297 | delayFirstRequest, 298 | baseDelay, 299 | factor, 300 | maxRetryCount, 301 | maxDelay, 302 | }); 303 | 304 | let requestCount = 0; 305 | 306 | const doFn = jest.fn(() => { 307 | if (requestCount === 3) { 308 | requestCount += 1; 309 | return Promise.resolve(successfulResponseData); 310 | } 311 | requestCount += 1; 312 | return Promise.resolve(failedResponseData); 313 | }); 314 | const request: Request = { 315 | do: doFn, 316 | }; 317 | 318 | /** 319 | * 1st request: 0s 320 | * 2nd request: 10ms - baseDelay 100 {maxDelay 10} 321 | * 3rd request: 10ms - baseDelay 100 * factor 1.25 * retryCount 1 {maxDelay 10} 322 | * 4th request: 10ms - baseDelay 100 * factor 1.25 * retryCount 2 {maxDelay 10} 323 | * total: ~30ms 324 | */ 325 | 326 | const then = Date.now(); 327 | await strategy.request(request); 328 | const now = Date.now(); 329 | 330 | const firstRequestTime = 0; 331 | const secondRequestTime = maxDelay; 332 | const thirdRequestTime = maxDelay; 333 | const forthRequestTime = maxDelay; 334 | // buffer is used to variance of tests 335 | const bufferTime = 5; 336 | 337 | const totalTime = 338 | firstRequestTime + 339 | secondRequestTime + 340 | thirdRequestTime + 341 | forthRequestTime - 342 | bufferTime; 343 | 344 | expect(now).toBeGreaterThan(then); 345 | 346 | expect(now - then).toBeGreaterThanOrEqual(totalTime); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/ExponentialBackoffRequestStrategy.ts: -------------------------------------------------------------------------------- 1 | import { Sleep } from '../utilities/sleep'; 2 | import { getIsSuccessfulHttpStatus } from '../utilities/getIsSuccessfulHttpStatus'; 3 | import { HttpRequestStrategy } from '../HttpRequestStrategies/HttpRequestStrategy'; 4 | import { Request, HttpResponse } from '../Adaptors'; 5 | 6 | export interface ExponentialBackoffOptions { 7 | /** Determines if the first request will be delayed */ 8 | delayFirstRequest?: boolean; 9 | /** The maximum number of retries to attempt, default is 5, set to 0 for indefinite retries */ 10 | maxRetryCount?: number; 11 | /** The base delay in milliseconds, will be used to calculate backoff */ 12 | baseDelay?: number; 13 | /** The maximum delay to use; set to -1 or undefined for no cap */ 14 | maxDelay?: number; 15 | /** The factor that will be used to grow the delay */ 16 | factor?: number; 17 | } 18 | 19 | /** Retries HTTP requests with a specified backoff strategy until the max retry count. */ 20 | export class ExponentialBackoffRequestStrategy implements HttpRequestStrategy { 21 | /** TOO MANY REQUESTS STATUS CODE */ 22 | private TOO_MANY_REQUESTS_STATUS = 429; 23 | 24 | private delayFirstRequest: boolean; 25 | private maxRetryCount: number; 26 | private baseDelay: number; 27 | private factor: number; 28 | private maxDelay: number; 29 | 30 | constructor(private options: ExponentialBackoffOptions = {}) { 31 | const { delayFirstRequest, maxRetryCount, baseDelay, factor, maxDelay } = 32 | this.options; 33 | this.delayFirstRequest = delayFirstRequest ?? false; 34 | this.maxRetryCount = maxRetryCount ?? 5; 35 | this.baseDelay = baseDelay ?? 100; 36 | this.factor = factor ?? 2; 37 | this.maxDelay = maxDelay ?? -1; 38 | } 39 | 40 | public async request( 41 | request: Request, 42 | ): Promise> { 43 | let response: HttpResponse; 44 | let retryCount = 0; 45 | let isSuccessfulHttpStatus = false; 46 | let isTooManyRequests = false; 47 | let isAtRetryLimit = false; 48 | let delay = this.baseDelay; 49 | 50 | do { 51 | if (this.getShouldDelay(retryCount)) { 52 | await Sleep(delay); 53 | } 54 | retryCount += 1; 55 | response = await request.do(); 56 | isSuccessfulHttpStatus = getIsSuccessfulHttpStatus(response.status); 57 | isTooManyRequests = response.status === this.TOO_MANY_REQUESTS_STATUS; 58 | isAtRetryLimit = this.getIsAtRetryMax(retryCount); 59 | delay *= this.factor * retryCount; 60 | // set delay to max delay if delay is greater than max delay 61 | if (this.maxDelay > -1 && delay > this.maxDelay) { 62 | delay = this.maxDelay; 63 | } 64 | } while (!isSuccessfulHttpStatus && !isTooManyRequests && !isAtRetryLimit); 65 | return response; 66 | } 67 | 68 | private getIsAtRetryMax(retryCount: number): boolean { 69 | return this.maxRetryCount === 0 ? false : retryCount >= this.maxRetryCount; 70 | } 71 | 72 | private getShouldDelay(retryCount: number): boolean { 73 | return retryCount !== 0 || this.delayFirstRequest; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/HttpRequestStrategy.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse, Request } from '../Adaptors'; 2 | 3 | /** How HTTP calls will be handled. */ 4 | export interface HttpRequestStrategy { 5 | /** Wrapper request around axios to add request and response logic */ 6 | request: (request: Request) => Promise>; 7 | } 8 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/MaxRetryHttpRequestStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { MaxRetryHttpRequestStrategy, HttpResponse } from '../index'; 2 | import { Request } from '../Adaptors'; 3 | 4 | const successfulResponseData: HttpResponse = { 5 | data: 'data', 6 | status: 200, 7 | headers: {}, 8 | statusText: 'success', 9 | }; 10 | 11 | const failedResponseData: HttpResponse = { 12 | status: 400, 13 | headers: {}, 14 | data: undefined, 15 | statusText: 'bad model', 16 | }; 17 | 18 | const tooManyRequestsResponseData: HttpResponse = { 19 | status: 429, 20 | headers: {}, 21 | data: undefined, 22 | statusText: 'too many requests', 23 | }; 24 | 25 | describe('MaxRetryHttpRequestStrategy', () => { 26 | beforeEach(() => { 27 | jest.resetModules(); 28 | jest.resetAllMocks(); 29 | }); 30 | 31 | it('be defined', () => { 32 | expect(new MaxRetryHttpRequestStrategy()).toBeDefined(); 33 | }); 34 | 35 | it('default maxRetryCount', () => { 36 | const strategy = new MaxRetryHttpRequestStrategy(); 37 | expect((strategy as any).maxRetryCount).toEqual(5); 38 | }); 39 | 40 | it('accept a maxRetryCount', () => { 41 | const maxRetryCount = 10; 42 | const strategy = new MaxRetryHttpRequestStrategy(maxRetryCount); 43 | expect((strategy as any).maxRetryCount).toEqual(maxRetryCount); 44 | }); 45 | 46 | it('request once on a success response', async () => { 47 | expect.assertions(2); 48 | const strategy = new MaxRetryHttpRequestStrategy(); 49 | const doFn = jest.fn(() => Promise.resolve(successfulResponseData)); 50 | 51 | const request: Request = { 52 | do: doFn, 53 | }; 54 | 55 | const response = await strategy.request(request); 56 | 57 | expect(successfulResponseData.data).toEqual(response.data); 58 | expect(doFn).toHaveBeenCalledTimes(1); 59 | }); 60 | 61 | it('request until successful, 1 failed, 1 success, 5 max', async () => { 62 | expect.assertions(2); 63 | const strategy = new MaxRetryHttpRequestStrategy(5); 64 | 65 | let requestCount = 0; 66 | 67 | const doFn = jest.fn(() => { 68 | if (requestCount === 0) { 69 | requestCount += 1; 70 | return Promise.resolve(failedResponseData); 71 | } 72 | requestCount += 1; 73 | return Promise.resolve(successfulResponseData); 74 | }); 75 | const request: Request = { 76 | do: doFn, 77 | }; 78 | 79 | const response = await strategy.request(request); 80 | 81 | expect(response.data).toEqual(successfulResponseData.data); 82 | expect(doFn).toHaveBeenCalledTimes(2); 83 | }); 84 | 85 | it('request until maxRetryCount, 10 failed, 0 success, 10 max', async () => { 86 | expect.assertions(2); 87 | const maxRetryCount = 10; 88 | const strategy = new MaxRetryHttpRequestStrategy(maxRetryCount); 89 | 90 | const doFn = jest.fn(() => { 91 | return Promise.resolve(failedResponseData); 92 | }); 93 | const request: Request = { 94 | do: doFn, 95 | }; 96 | 97 | const response = await strategy.request(request); 98 | 99 | expect(response.data).toEqual(failedResponseData.data); 100 | expect(doFn).toHaveBeenCalledTimes(maxRetryCount); 101 | }); 102 | 103 | it('request until hits TOO_MANY_REQUESTS_STATUS, 3 failed, 1 TOO_MANY..., 5 max', async () => { 104 | expect.assertions(2); 105 | const strategy = new MaxRetryHttpRequestStrategy(5); 106 | 107 | let requestCount = 0; 108 | 109 | const doFn = jest.fn(() => { 110 | if (requestCount === 3) { 111 | requestCount += 1; 112 | return Promise.resolve(tooManyRequestsResponseData); 113 | } 114 | requestCount += 1; 115 | return Promise.resolve(failedResponseData); 116 | }); 117 | 118 | const request: Request = { 119 | do: doFn, 120 | }; 121 | 122 | const response = await strategy.request(request); 123 | 124 | expect(response.data).toEqual(tooManyRequestsResponseData.data); 125 | expect(doFn).toHaveBeenCalledTimes(4); 126 | }); 127 | 128 | it('request forever if a zero is passed for maxRetryCount, 99 failed, 1 success..., 0 max', async () => { 129 | expect.assertions(2); 130 | const maxRetryCount = 0; 131 | const strategy = new MaxRetryHttpRequestStrategy(maxRetryCount); 132 | 133 | let requestCount = 0; 134 | 135 | const doFn = jest.fn(() => { 136 | if (requestCount === 99) { 137 | requestCount += 1; 138 | return Promise.resolve(successfulResponseData); 139 | } 140 | requestCount += 1; 141 | return Promise.resolve(failedResponseData); 142 | }); 143 | const request: Request = { 144 | do: doFn, 145 | }; 146 | 147 | const response = await strategy.request(request); 148 | 149 | expect(response.data).toEqual(successfulResponseData.data); 150 | expect(doFn).toHaveBeenCalledTimes(100); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/MaxRetryHttpRequestStrategy.ts: -------------------------------------------------------------------------------- 1 | import { Request, HttpResponse } from '../Adaptors'; 2 | import { ExponentialBackoffRequestStrategy } from './ExponentialBackoffRequestStrategy'; 3 | 4 | /** Retries HTTP requests immediately on non successful HTTP request until the max retry count. 5 | * Stops retrying when a TOO MANY REQUESTS STATUS is received (status code: 429) 6 | */ 7 | export class MaxRetryHttpRequestStrategy extends ExponentialBackoffRequestStrategy { 8 | /** 9 | * @param maxRetryCount - The maximum number of retries to attempt, default is 5, set to 0 for indefinite retries 10 | */ 11 | constructor(maxRetryCount: number = 5) { 12 | super({ 13 | delayFirstRequest: false, 14 | maxRetryCount, 15 | baseDelay: 0, 16 | factor: 1, 17 | maxDelay: 0, 18 | }); 19 | } 20 | 21 | public override async request( 22 | request: Request, 23 | ): Promise> { 24 | return await super.request(request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/TimeoutHttpRequestStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { TimeoutHttpRequestStrategy } from './TimeoutHttpRequestStrategy'; 2 | import { HttpResponse, Request } from '../index'; 3 | import { Sleep } from '../utilities/sleep'; 4 | 5 | const successfulResponseData: HttpResponse = { 6 | data: 'data', 7 | status: 200, 8 | headers: {}, 9 | statusText: 'success', 10 | }; 11 | 12 | const failedResponseData: HttpResponse = { 13 | data: undefined, 14 | status: 400, 15 | headers: {}, 16 | statusText: 'Bad Request', 17 | }; 18 | 19 | describe('TimeoutHttpRequestStrategy', () => { 20 | beforeEach(() => { 21 | jest.resetModules(); 22 | jest.resetAllMocks(); 23 | }); 24 | 25 | it('be defined', () => { 26 | expect(new TimeoutHttpRequestStrategy()).toBeDefined(); 27 | }); 28 | 29 | it('default timeout', () => { 30 | const strategy = new TimeoutHttpRequestStrategy(); 31 | expect((strategy as any).timeout).toEqual(10000); 32 | }); 33 | 34 | it('accept a timeout', () => { 35 | const timeout = 2000; 36 | const strategy = new TimeoutHttpRequestStrategy(timeout); 37 | expect((strategy as any).timeout).toEqual(timeout); 38 | }); 39 | 40 | it('return on success response less than timeout', async () => { 41 | expect.assertions(1); 42 | const strategy = new TimeoutHttpRequestStrategy(); 43 | const doFn = jest.fn(() => Promise.resolve(successfulResponseData)); 44 | 45 | const request: Request = { 46 | do: doFn, 47 | }; 48 | 49 | const response = await strategy.request(request); 50 | 51 | expect(successfulResponseData.data).toEqual(response.data); 52 | }); 53 | 54 | it('throw if request is longer than timeout', async () => { 55 | expect.assertions(2); 56 | const strategy = new TimeoutHttpRequestStrategy(100); 57 | 58 | const doFn = jest.fn(async () => { 59 | await Sleep(200); 60 | return Promise.resolve(successfulResponseData); 61 | }); 62 | const request: Request = { 63 | do: doFn, 64 | }; 65 | 66 | try { 67 | await strategy.request(request); 68 | throw new Error('it will not reach here'); 69 | } catch (e) { 70 | const error = e as Error; 71 | expect(error.message).toEqual('Request timed out'); 72 | } 73 | expect(doFn).toHaveBeenCalledTimes(1); 74 | }); 75 | 76 | it('throw if request returns error', async () => { 77 | expect.assertions(2); 78 | const strategy = new TimeoutHttpRequestStrategy(100); 79 | 80 | const doFn = jest.fn(async () => { 81 | return Promise.reject(failedResponseData); 82 | }); 83 | 84 | const request: Request = { 85 | do: doFn, 86 | }; 87 | 88 | try { 89 | await strategy.request(request); 90 | throw new Error('it will not reach here'); 91 | } catch (e) { 92 | const error = e as Partial>; 93 | expect(error.statusText).toEqual(failedResponseData.statusText); 94 | } 95 | expect(doFn).toHaveBeenCalledTimes(1); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/TimeoutHttpRequestStrategy.ts: -------------------------------------------------------------------------------- 1 | import { DefaultHttpRequestStrategy } from './DefaultHttpRequestStrategy'; 2 | import { Request, HttpResponse } from '../Adaptors'; 3 | import { TimeoutError } from '../errors/TimeoutError'; 4 | 5 | /** This strategy is used to set a timeout on a request */ 6 | export class TimeoutHttpRequestStrategy extends DefaultHttpRequestStrategy { 7 | /** 8 | * @param timeout - The max time a request can take before aborting 9 | */ 10 | constructor(private timeout: number = 10000) { 11 | super(); 12 | } 13 | 14 | public override request( 15 | request: Request, 16 | ): Promise> { 17 | return new Promise((resolve, reject) => { 18 | const timeout = setTimeout(() => { 19 | reject(new TimeoutError('Request timed out')); 20 | }, this.timeout); 21 | super 22 | .request(request) 23 | .then((response) => resolve(response)) 24 | .catch((error) => reject(error)) 25 | .finally(() => clearTimeout(timeout)); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/httpclient/src/HttpRequestStrategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HttpRequestStrategy'; 2 | export * from './DefaultHttpRequestStrategy'; 3 | export * from './MaxRetryHttpRequestStrategy'; 4 | export * from './ExponentialBackoffRequestStrategy'; 5 | export * from './TimeoutHttpRequestStrategy'; 6 | -------------------------------------------------------------------------------- /packages/httpclient/src/Logger.ts: -------------------------------------------------------------------------------- 1 | export type LogFunction = (message: string, ...args: unknown[]) => void; 2 | 3 | /** Logger Interface */ 4 | export interface Logger { 5 | info: LogFunction; 6 | warn: LogFunction; 7 | error: LogFunction; 8 | debug: LogFunction; 9 | } 10 | -------------------------------------------------------------------------------- /packages/httpclient/src/errors/AbortError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './HttpError'; 2 | 3 | export class AbortError extends HttpError { 4 | constructor(message: string, options?: ErrorOptions) { 5 | super(message, options); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/httpclient/src/errors/HttpError.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | public isHttpClientError = true; 3 | 4 | constructor(message: string, options?: ErrorOptions) { 5 | super(message, options); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/httpclient/src/errors/TimeoutError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './HttpError'; 2 | 3 | export class TimeoutError extends HttpError { 4 | constructor(message: string, options?: ErrorOptions) { 5 | super(message, options); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/httpclient/src/errors/isHttpError.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpError, isHttpError } from '../index'; 2 | 3 | describe('isHttpError', () => { 4 | it('return true for HttpError', () => { 5 | const error = new HttpError('Internal Server Error'); 6 | expect(isHttpError(error)).toBe(true); 7 | }); 8 | 9 | it('return false for non-HttpError', () => { 10 | const error = new Error('Internal Server Error'); 11 | expect(isHttpError(error)).toBe(false); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/httpclient/src/errors/isHttpError.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './HttpError'; 2 | 3 | export function isHttpError(error: any): error is HttpError { 4 | return 'isHttpClientError' in error; 5 | } 6 | -------------------------------------------------------------------------------- /packages/httpclient/src/examples/example-basic.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '../HttpClient'; 2 | import { PokemonApi } from '@seriouslag/examples'; 3 | 4 | const pokemonApiUrl = 'https://pokeapi.co/api/v2'; 5 | 6 | const httpClient = new HttpClient(); 7 | const pokemonApi = new PokemonApi(pokemonApiUrl, httpClient); 8 | 9 | const main = async () => { 10 | const result = await pokemonApi.fetchPokemonPage(); 11 | console.log(result); 12 | }; 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /packages/httpclient/src/examples/example-cancelToken.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '../HttpClient'; 2 | import { PokemonApi } from '@seriouslag/examples'; 3 | 4 | const pokemonApiUrl = 'https://pokeapi.co/api/v2'; 5 | 6 | const httpClient = new HttpClient(); 7 | const pokemonApi = new PokemonApi(pokemonApiUrl, httpClient); 8 | 9 | const cancelToken = new AbortController(); 10 | 11 | const main = async () => { 12 | // cancel the token before calling the api 13 | // token can be canceled after the after call has been called but before it resolves and the result will be the same; (abort error will be thrown) 14 | cancelToken.abort(); 15 | try { 16 | const result = await pokemonApi.fetchPokemonPage(cancelToken); 17 | // Will not reach here 18 | console.log(result); 19 | } catch (e) { 20 | console.log('Request failed', e); 21 | } 22 | }; 23 | 24 | main(); 25 | -------------------------------------------------------------------------------- /packages/httpclient/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HttpClient'; 2 | export * from './Logger'; 3 | export * from './errors/HttpError'; 4 | export * from './errors/AbortError'; 5 | export * from './errors/isHttpError'; 6 | export * from './HttpRequestStrategies'; 7 | export * from './utilities/getIsSuccessfulHttpStatus'; 8 | export * from './Adaptors'; 9 | export * from './strings'; 10 | -------------------------------------------------------------------------------- /packages/httpclient/src/strings.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_URL = 'Url must be a string'; 2 | export const ABORT_MESSAGE = 'Aborted by token'; 3 | -------------------------------------------------------------------------------- /packages/httpclient/src/utilities/getIsSuccessfulHttpStatus.test.ts: -------------------------------------------------------------------------------- 1 | import { getIsSuccessfulHttpStatus } from './getIsSuccessfulHttpStatus'; 2 | 3 | describe('getIsSuccessfulHttpStatus', () => { 4 | it('return true if status is between 200 and 299', () => { 5 | expect.assertions(100); 6 | for (let i = 200; i <= 299; i++) { 7 | const result = getIsSuccessfulHttpStatus(i); 8 | expect(result).toBe(true); 9 | } 10 | }); 11 | it('return false if status is greater than 300', () => { 12 | expect.assertions(1); 13 | const result = getIsSuccessfulHttpStatus(300); 14 | expect(result).toBe(false); 15 | }); 16 | it('return false if status is less than 200', () => { 17 | expect.assertions(1); 18 | const result = getIsSuccessfulHttpStatus(199); 19 | expect(result).toBe(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/httpclient/src/utilities/getIsSuccessfulHttpStatus.ts: -------------------------------------------------------------------------------- 1 | /** Function to determine if a HTTP status code is in the successful range (2XX) */ 2 | export function getIsSuccessfulHttpStatus(status: number): boolean { 3 | return status >= 200 && status < 300; 4 | } 5 | -------------------------------------------------------------------------------- /packages/httpclient/src/utilities/sleep.test.ts: -------------------------------------------------------------------------------- 1 | import { Sleep } from './sleep'; 2 | 3 | describe('Sleep', () => { 4 | it('Sleeps', async () => { 5 | const then = Date.now(); 6 | await Sleep(200); 7 | const now = Date.now(); 8 | expect(now).toBeGreaterThan(then); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/httpclient/src/utilities/sleep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sleeps for set amount of time (awaitable setTimeout) 3 | * @param milliseconds time to sleep in ms 4 | * @returns 5 | */ 6 | export function Sleep(milliseconds: number): Promise { 7 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); 8 | } 9 | -------------------------------------------------------------------------------- /packages/httpclient/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "composite": true, 6 | "noEmit": false, 7 | "baseUrl": "src", 8 | "rootDir": "src", 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "./src/**/*.ts" 13 | ], 14 | "exclude": [ 15 | "dist/**/*", 16 | "node_modules/**/*" 17 | ] 18 | } -------------------------------------------------------------------------------- /packages/httpclient/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "moduleResolution": "Node10", 6 | "declaration": true, 7 | "composite": true, 8 | "target": "ESNext", 9 | "sourceMap": false, 10 | "declarationMap": false 11 | }, 12 | "include": [ 13 | "./src/**/*.ts" 14 | ], 15 | "exclude": [ 16 | "./**/*.test.*", 17 | "node_modules/**/*", 18 | "src/examples/**/*", 19 | "dist/**/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /packages/httpclient/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDir": "./src", 6 | "paths": { 7 | "@seriouslag/*": ["../*/src/index"] 8 | } 9 | }, 10 | "references": [ 11 | { 12 | "path": "../examples" 13 | } 14 | ], 15 | "include": [ 16 | "./src/**/*.ts", 17 | ], 18 | "exclude": [ 19 | "dist/**/*", 20 | "node_modules/**/*" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "composite": true, 5 | "isolatedModules": true, 6 | "baseUrl": ".", 7 | "target": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "module": "Preserve", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitReturns": true, 15 | "noImplicitOverride": true, 16 | "noImplicitAny": true, 17 | "noImplicitThis": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "paths": { 20 | "@seriouslag/*": ["./packages/*/src/index"], 21 | } 22 | }, 23 | "references": [ 24 | { 25 | "path": "./packages/httpclient" 26 | }, 27 | { 28 | "path": "./packages/httpclient-axios" 29 | } 30 | ], 31 | "exclude": [ 32 | "dist/**/*", 33 | "**/dist/**/*", 34 | "node_modules", 35 | "**/*.test.ts" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "ESNext", 6 | "declaration": true, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "composite": true, 5 | "isolatedModules": true, 6 | "target": "ESNext", 7 | "module": "Preserve", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "lib": ["ESNext", "DOM"], 11 | "skipLibCheck": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noImplicitOverride": true, 15 | "noImplicitAny": true, 16 | "noImplicitThis": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "paths": { 19 | "@seriouslag/*": [ 20 | "./packages/*/src/", 21 | ] 22 | } 23 | }, 24 | "references": [ 25 | { 26 | "path": "packages/httpclient" 27 | }, 28 | { 29 | "path": "packages/httpclient-axios" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "target": "ES2020", 6 | "module": "commonjs", 7 | "allowJs": true, 8 | "noEmit": true, 9 | "skipLibCheck": true, 10 | } 11 | } 12 | --------------------------------------------------------------------------------