├── .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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
160 | - DefaultHttpRequestStrategy - Throws when a response's status is not 2XX
161 | - ExponentialBackoffRequestStrategy - Retries requests with a backoff. Throws when a response's status is not 2XX
162 | - MaxRetryHttpRequestStrategy - Retries requests. Throws when a response's status is not 2XX
163 | - TimeoutHttpRequestStrategy - Requests have are canceled if a request takes longer then provided timeout. Throws when a response's status is not 2XX
164 |
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 |
--------------------------------------------------------------------------------