├── .eslintrc.json ├── .github └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── client.test.ts ├── client.ts └── index.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { "project": ["./tsconfig.json"] }, 5 | "plugins": ["@typescript-eslint"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.19.1] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | registry-url: 'https://registry.npmjs.org' 22 | - run: npm ci 23 | - run: npm publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [14.19.1] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # build 4 | /dist 5 | /node_modules 6 | 7 | # debug 8 | /scratch 9 | 10 | # typescript 11 | *.tsbuildinfo 12 | 13 | # eslint cache 14 | .eslintcache 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.20.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "overrides": [], 7 | "printWidth": 80, 8 | "semi": true, 9 | "singleQuote": true, 10 | "tabWidth": 2, 11 | "trailingComma": "all", 12 | "useTabs": false 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Swell Commerce Corp. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Swell API library for NodeJS 2 | 3 | [Swell](https://www.swell.is) is a customizable, API-first platform for powering modern B2C/B2B shopping experiences and marketplaces. Build and connect anything using your favorite technologies, and provide admins with an easy to use dashboard. 4 | 5 | ## Install 6 | 7 | npm install swell-node --save 8 | 9 | ## Connect 10 | 11 | ```javascript 12 | const { swell } = require('swell-node'); 13 | 14 | swell.init('my-store', 'secret-key'); 15 | ``` 16 | 17 | To connect to multiple stores in the same process, use `swell.createClient()`: 18 | 19 | ```javascript 20 | const { swell } = require('swell-node'); 21 | 22 | const client1 = swell.createClient('my-store-1', 'secret-key-1'); 23 | const client2 = swell.createClient('my-store-2', 'secret-key-2'); 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```javascript 29 | try { 30 | const { data } = await swell.get('/products', { 31 | active: true 32 | }); 33 | console.log(data); 34 | } catch (err) { 35 | console.error(err.message); 36 | } 37 | ``` 38 | 39 | ## Documentation 40 | 41 | This library is intended for use with the Swell Backend API: https://developers.swell.is/backend-api 42 | 43 | ## Contributing 44 | 45 | Pull requests are welcome 46 | 47 | ## License 48 | 49 | MIT 50 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | clearMocks: true, 5 | preset: 'ts-jest', 6 | restoreMocks: true, 7 | testEnvironment: 'node', 8 | transform: { 9 | '^.+\\.ts$': ['ts-jest', { diagnostics: { ignoreCodes: ['TS151001'] } }], 10 | }, 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swell-node", 3 | "version": "6.0.0", 4 | "description": "Swell API client for NodeJS", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "engines": { 11 | "node": ">=16" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/swellstores/swell-node.git" 16 | }, 17 | "keywords": [ 18 | "swell", 19 | "ecommerce", 20 | "api" 21 | ], 22 | "author": "Swell", 23 | "license": "MIT", 24 | "homepage": "https://github.com/swellstores/swell-node", 25 | "bugs:": "https://github.com/swellstores/swell-node/issues", 26 | "dependencies": { 27 | "axios": "^1.7.7", 28 | "http-cookie-agent": "^5.0.4", 29 | "retry": "^0.13.1", 30 | "tough-cookie": "^4.1.4" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^29.5.11", 34 | "@types/node": "^14.14.31", 35 | "@types/retry": "^0.12.5", 36 | "@types/tough-cookie": "^4.0.5", 37 | "@typescript-eslint/eslint-plugin": "^6.18.1", 38 | "@typescript-eslint/parser": "^6.18.1", 39 | "axios-mock-adapter": "^2.0.0", 40 | "eslint": "^8.56.0", 41 | "eslint-config-prettier": "^9.1.0", 42 | "eslint-plugin-prettier": "^5.1.2", 43 | "jest": "^29.7.0", 44 | "prettier": "^3.1.1", 45 | "rimraf": "^5.0.5", 46 | "ts-jest": "^29.1.1", 47 | "ts-node": "^10.9.2", 48 | "typescript": "^5.3.2" 49 | }, 50 | "scripts": { 51 | "clean": "rimraf dist", 52 | "prebuild": "npm run clean", 53 | "build": "tsc", 54 | "prepare": "npm run build", 55 | "lint": "eslint src", 56 | "prettier": "prettier --write \"**/*.ts\"", 57 | "test": "jest", 58 | "test:watch": "jest --watch" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/client.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | 4 | import { Client, HttpMethod } from './client'; 5 | 6 | const mock = new MockAdapter(axios); 7 | 8 | describe('Client', () => { 9 | describe('#constructor', () => { 10 | test('creates an instance without initialization', () => { 11 | const client = new Client(); 12 | 13 | expect(client.options).toEqual({}); 14 | expect(client.httpClient).toStrictEqual(null); 15 | }); 16 | 17 | test('creates an instance with initialization', () => { 18 | const client = new Client('id', 'key', { timeout: 1000 }); 19 | 20 | expect(client.options.timeout).toEqual(1000); 21 | expect(client.httpClient).toBeDefined(); 22 | }); 23 | }); // describe: #constructor 24 | 25 | describe('#createClient', () => { 26 | let client: Client; 27 | 28 | beforeEach(() => { 29 | client = new Client(); 30 | }); 31 | 32 | it('instantiates multiple clients', () => { 33 | const one = client.createClient('id', 'key1'); 34 | expect(one instanceof Client).toBe(true); 35 | expect(one.httpClient?.defaults.headers.common['X-Header']).toBe( 36 | undefined, 37 | ); 38 | 39 | const two = client.createClient('id', 'key2', { 40 | headers: { 'X-Header': 'Foo' }, 41 | }); 42 | expect(two instanceof Client).toBe(true); 43 | expect(two.httpClient?.defaults.headers.common['X-Header']).toEqual( 44 | 'Foo', 45 | ); 46 | }); 47 | }); // describe: #createClient 48 | 49 | describe('#init', () => { 50 | let client: Client; 51 | 52 | beforeEach(() => { 53 | client = new Client(); 54 | }); 55 | 56 | test('throws an error if "id" is missing', () => { 57 | expect(() => { 58 | client.init(); 59 | }).toThrow("Swell store 'id' is required to connect"); 60 | }); 61 | 62 | test('throws an error if "key" is missing', () => { 63 | expect(() => { 64 | client.init('id'); 65 | }).toThrow("Swell store 'key' is required to connect"); 66 | }); 67 | 68 | test('applies default options when none are specified', () => { 69 | client.init('id', 'key'); 70 | 71 | expect(client.options).toEqual({ 72 | headers: {}, 73 | url: 'https://api.swell.store', 74 | verifyCert: true, 75 | version: 1, 76 | retries: 0, 77 | }); 78 | }); 79 | 80 | test('overrides default options', () => { 81 | client.init('id', 'key', { 82 | verifyCert: false, 83 | version: 2, 84 | }); 85 | 86 | expect(client.options).toEqual({ 87 | headers: {}, 88 | url: 'https://api.swell.store', 89 | verifyCert: false, 90 | version: 2, 91 | retries: 0, 92 | }); 93 | }); 94 | 95 | describe('concerning headers', () => { 96 | test('sets default content-type header', () => { 97 | client.init('id', 'key'); 98 | expect( 99 | client.httpClient?.defaults.headers.common['Content-Type'], 100 | ).toEqual('application/json'); 101 | }); 102 | 103 | test('sets default user-agent header', () => { 104 | client.init('id', 'key'); 105 | expect( 106 | client.httpClient?.defaults.headers.common['User-Agent'], 107 | ).toMatch(/^swell-node@.+$/); 108 | }); 109 | 110 | test('sets default x-user-application header', () => { 111 | client.init('id', 'key'); 112 | 113 | expect( 114 | client.httpClient?.defaults.headers.common['X-User-Application'], 115 | ).toEqual( 116 | `${process.env.npm_package_name}@${process.env.npm_package_version}`, 117 | ); 118 | }); 119 | 120 | test('sets authorization header', () => { 121 | client.init('id', 'key'); 122 | 123 | const authToken: string = Buffer.from('id:key', 'utf8').toString( 124 | 'base64', 125 | ); 126 | 127 | expect( 128 | client.httpClient?.defaults.headers.common['Authorization'], 129 | ).toEqual(`Basic ${authToken}`); 130 | }); 131 | 132 | test('passes in extra headers', () => { 133 | const headers = { 134 | 'X-Header-1': 'foo', 135 | 'X-Header-2': 'bar', 136 | }; 137 | 138 | client.init('id', 'key', { headers }); 139 | 140 | expect( 141 | client.httpClient?.defaults.headers.common['X-Header-1'], 142 | ).toEqual('foo'); 143 | expect( 144 | client.httpClient?.defaults.headers.common['X-Header-2'], 145 | ).toEqual('bar'); 146 | }); 147 | }); // describe: concerning headers 148 | }); // describe: #init 149 | 150 | describe('#request', () => { 151 | test('makes a GET request', async () => { 152 | const client = new Client('id', 'key'); 153 | 154 | mock.onGet('/products/:count').reply(200, 42); 155 | 156 | const response = await client.request( 157 | HttpMethod.get, 158 | '/products/:count', 159 | {}, 160 | ); 161 | 162 | expect(response).toEqual(42); 163 | }); 164 | 165 | test('makes a POST request', async () => { 166 | const client = new Client('id', 'key'); 167 | 168 | mock.onPost('/products').reply(200, 'result'); 169 | 170 | const response = await client.request(HttpMethod.post, '/products', {}); 171 | 172 | expect(response).toEqual('result'); 173 | }); 174 | 175 | test('makes a PUT request', async () => { 176 | const client = new Client('id', 'key'); 177 | 178 | mock.onPut('/products/{id}').reply(200, 'result'); 179 | 180 | const response = await client.request(HttpMethod.put, '/products/{id}', { 181 | id: 'foo', 182 | }); 183 | 184 | expect(response).toEqual('result'); 185 | }); 186 | 187 | test('makes a DELETE request', async () => { 188 | const client = new Client('id', 'key'); 189 | 190 | mock.onDelete('/products/{id}').reply(200, 'result'); 191 | 192 | const response = await client.request( 193 | HttpMethod.delete, 194 | '/products/{id}', 195 | { id: 'foo' }, 196 | ); 197 | 198 | expect(response).toEqual('result'); 199 | }); 200 | 201 | test('makes a request with headers', async () => { 202 | const client = new Client('id', 'key'); 203 | 204 | mock.onGet('/products/:count').reply((config) => { 205 | const headers = Object.fromEntries( 206 | Object.entries(config.headers || {}), 207 | ); 208 | return [200, headers['X-Foo']]; 209 | }); 210 | 211 | const response = await client.request( 212 | HttpMethod.get, 213 | '/products/:count', 214 | {}, 215 | { 'X-Foo': 'bar' }, 216 | ); 217 | 218 | expect(response).toEqual('bar'); 219 | }); 220 | 221 | test('handles an error response', async () => { 222 | const client = new Client('id', 'key'); 223 | 224 | mock.onGet('/products/:count').reply(500, 'Internal Server Error'); 225 | 226 | await expect( 227 | client.request(HttpMethod.get, '/products/:count', {}), 228 | ).rejects.toThrow(new Error('Internal Server Error')); 229 | }); 230 | 231 | test('handles a timeout', async () => { 232 | const client = new Client('id', 'key'); 233 | 234 | mock.onGet('/products/:count').timeout(); 235 | 236 | await expect( 237 | client.request(HttpMethod.get, '/products/:count', {}), 238 | ).rejects.toThrow(new Error('timeout of 0ms exceeded')); 239 | }); 240 | }); // describe: #request 241 | 242 | describe('#retry', () => { 243 | test('handle zero retries by default', async () => { 244 | const client = new Client('id', 'key'); 245 | 246 | // Simulate timeout error 247 | mock.onGet('/products/:count').timeoutOnce(); 248 | 249 | await expect( 250 | client.request(HttpMethod.get, '/products/:count', {}), 251 | ).rejects.toThrow(new Error('timeout of 0ms exceeded')); 252 | }); 253 | 254 | test('handle retries option', async () => { 255 | const client = new Client('id', 'key', { retries: 3 }); 256 | 257 | // Simulate server failure on first 2 attempts and success on the third 258 | mock 259 | .onGet('/products:variants/:count') 260 | .timeoutOnce() 261 | .onGet('/products:variants/:count') 262 | .timeoutOnce() 263 | .onGet('/products:variants/:count') 264 | .replyOnce(200, 42); 265 | 266 | const response = await client.request( 267 | HttpMethod.get, 268 | '/products:variants/:count', 269 | {}, 270 | ); 271 | expect(response).toEqual(42); 272 | }); 273 | 274 | test('handle return error if response not received after retries', async () => { 275 | const client = new Client('id', 'key', { retries: 3 }); 276 | 277 | // Simulate server failure on first 4 attempts and success on the fifth 278 | mock 279 | .onGet('/categories/:count') 280 | .timeoutOnce() 281 | .onGet('/categories/:count') 282 | .timeoutOnce() 283 | .onGet('/categories/:count') 284 | .timeoutOnce() 285 | .onGet('/categories/:count') 286 | .timeoutOnce() 287 | .onGet('/categories/:count') 288 | .replyOnce(200, 42); 289 | 290 | await expect( 291 | client.request(HttpMethod.get, '/categories/:count', {}), 292 | ).rejects.toThrow(new Error('timeout of 0ms exceeded')); 293 | }); 294 | 295 | test('handle return error code without retries', async () => { 296 | const client = new Client('id', 'key', { retries: 3 }); 297 | 298 | // Simulate server returns 404 error with 1st attempt 299 | let attemptsCouter = 0; 300 | mock.onGet('/:files/robots.txt').reply(() => { 301 | attemptsCouter++; 302 | return [404, 'Not found']; 303 | }); 304 | 305 | await expect( 306 | client.request(HttpMethod.get, '/:files/robots.txt', {}), 307 | ).rejects.toThrow(); 308 | expect(attemptsCouter).toBe(1); 309 | }); 310 | }); // describe: #retry 311 | }); 312 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import * as axios from 'axios'; 2 | import * as retry from 'retry'; 3 | import { CookieJar } from 'tough-cookie'; 4 | import { HttpCookieAgent, HttpsCookieAgent } from 'http-cookie-agent/http'; 5 | 6 | export const enum HttpMethod { 7 | get = 'get', 8 | post = 'post', 9 | put = 'put', 10 | delete = 'delete', 11 | } 12 | 13 | export interface HttpHeaders { 14 | [header: string]: axios.AxiosHeaderValue; 15 | } 16 | 17 | export interface ClientOptions { 18 | url?: string; 19 | verifyCert?: boolean; 20 | version?: number; 21 | timeout?: number; 22 | headers?: HttpHeaders; 23 | retries?: number; 24 | } 25 | 26 | const MODULE_VERSION: string = (({ name, version }) => { 27 | return `${name}@${version}`; 28 | })(require('../package.json')); // eslint-disable-line @typescript-eslint/no-var-requires 29 | 30 | const USER_APP_VERSION: string | undefined = 31 | process.env.npm_package_name && process.env.npm_package_version 32 | ? `${process.env.npm_package_name}@${process.env.npm_package_version}` 33 | : undefined; 34 | 35 | const DEFAULT_OPTIONS: Readonly = Object.freeze({ 36 | url: 'https://api.swell.store', 37 | verifyCert: true, 38 | version: 1, 39 | headers: {}, 40 | retries: 0, // 0 => no retries 41 | }); 42 | 43 | class ApiError extends Error { 44 | message: string; 45 | code?: string; 46 | status?: number; 47 | headers: HttpHeaders; 48 | 49 | constructor( 50 | message: string, 51 | code?: string, 52 | status?: number, 53 | headers: HttpHeaders = {}, 54 | ) { 55 | super(); 56 | 57 | this.message = message; 58 | this.code = code; 59 | this.status = status; 60 | this.headers = headers; 61 | } 62 | } 63 | 64 | // We should retry request only in case of timeout or disconnect 65 | const RETRY_CODES = new Set(['ECONNABORTED', 'ECONNREFUSED']); 66 | 67 | /** 68 | * Swell API Client. 69 | */ 70 | export class Client { 71 | clientId: string; 72 | clientKey: string; 73 | options: ClientOptions; 74 | httpClient: axios.AxiosInstance | null; 75 | 76 | constructor( 77 | clientId?: string, 78 | clientKey?: string, 79 | options: ClientOptions = {}, 80 | ) { 81 | this.clientId = typeof clientId === 'string' ? clientId : ''; 82 | this.clientKey = typeof clientKey === 'string' ? clientKey : ''; 83 | this.options = {}; 84 | this.httpClient = null; 85 | 86 | if (clientId) { 87 | this.init(clientId, clientKey, options); 88 | } 89 | } 90 | 91 | /** 92 | * Convenience method to create a new client instance from a singleton instance. 93 | */ 94 | createClient( 95 | clientId: string, 96 | clientKey: string, 97 | options: ClientOptions = {}, 98 | ): Client { 99 | return new Client(clientId, clientKey, options); 100 | } 101 | 102 | init(clientId?: string, clientKey?: string, options?: ClientOptions): void { 103 | if (!clientId) { 104 | throw new Error("Swell store 'id' is required to connect"); 105 | } 106 | 107 | if (!clientKey) { 108 | throw new Error("Swell store 'key' is required to connect"); 109 | } 110 | 111 | this.clientId = clientId; 112 | this.clientKey = clientKey; 113 | 114 | this.options = { ...DEFAULT_OPTIONS, ...options }; 115 | 116 | this._initHttpClient(); 117 | } 118 | 119 | _initHttpClient(): void { 120 | const { url, timeout, verifyCert, headers } = this.options; 121 | 122 | const authToken = Buffer.from( 123 | `${this.clientId}:${this.clientKey}`, 124 | 'utf8', 125 | ).toString('base64'); 126 | 127 | const jar = new CookieJar(); 128 | 129 | this.httpClient = axios.create({ 130 | baseURL: url, 131 | headers: { 132 | common: { 133 | ...headers, 134 | 'Content-Type': 'application/json', 135 | 'User-Agent': MODULE_VERSION, 136 | 'X-User-Application': USER_APP_VERSION, 137 | Authorization: `Basic ${authToken}`, 138 | }, 139 | }, 140 | httpAgent: new HttpCookieAgent({ 141 | cookies: { jar }, 142 | }), 143 | httpsAgent: new HttpsCookieAgent({ 144 | cookies: { jar }, 145 | rejectUnauthorized: Boolean(verifyCert), 146 | }), 147 | ...(timeout ? { timeout } : undefined), 148 | }); 149 | } 150 | 151 | get(url: string, data?: unknown, headers?: HttpHeaders): Promise { 152 | return this.request(HttpMethod.get, url, data, headers); 153 | } 154 | 155 | post(url: string, data: unknown, headers?: HttpHeaders): Promise { 156 | return this.request(HttpMethod.post, url, data, headers); 157 | } 158 | 159 | put(url: string, data: unknown, headers?: HttpHeaders): Promise { 160 | return this.request(HttpMethod.put, url, data, headers); 161 | } 162 | 163 | delete(url: string, data?: unknown, headers?: HttpHeaders): Promise { 164 | return this.request(HttpMethod.delete, url, data, headers); 165 | } 166 | 167 | async request( 168 | method: HttpMethod, 169 | url: string, 170 | data?: unknown, 171 | headers?: HttpHeaders, 172 | ): Promise { 173 | // Prepare url and data for request 174 | const requestParams = transformRequest(method, url, data, headers); 175 | 176 | return new Promise((resolve, reject) => { 177 | const { retries } = this.options; 178 | 179 | const operation = retry.operation({ 180 | retries, 181 | minTimeout: 20, 182 | maxTimeout: 100, 183 | factor: 1, 184 | randomize: false, 185 | }); 186 | 187 | operation.attempt(async () => { 188 | if (this.httpClient === null) { 189 | return reject(new Error('Swell API client not initialized')); 190 | } 191 | 192 | try { 193 | const response = await this.httpClient.request(requestParams); 194 | resolve(transformResponse(response).data); 195 | } catch (error) { 196 | // Attempt retry if we encounter a timeout or connection error 197 | const code = axios.isAxiosError(error) ? error?.code : null; 198 | 199 | if ( 200 | code && 201 | RETRY_CODES.has(code) && 202 | operation.retry(error as Error) 203 | ) { 204 | return; 205 | } 206 | reject(transformError(error)); 207 | } 208 | }); 209 | }); 210 | } 211 | } 212 | 213 | /** 214 | * Transforms the request. 215 | * 216 | * @param method The HTTP method 217 | * @param url The request URL 218 | * @param data The request data 219 | * @return a normalized request object 220 | */ 221 | function transformRequest( 222 | method: HttpMethod, 223 | url: string, 224 | data: unknown, 225 | headers?: HttpHeaders, 226 | ): axios.AxiosRequestConfig { 227 | return { 228 | method, 229 | url: typeof url?.toString === 'function' ? url.toString() : '', 230 | data: data !== undefined ? data : null, 231 | headers, 232 | }; 233 | } 234 | 235 | interface TransformedResponse { 236 | data: T; 237 | headers: HttpHeaders; 238 | status: number; 239 | } 240 | 241 | /** 242 | * Transforms the response. 243 | * 244 | * @param response The response object 245 | * @return a normalized response object 246 | */ 247 | function transformResponse( 248 | response: axios.AxiosResponse, 249 | ): TransformedResponse { 250 | const { data, headers, status } = response; 251 | return { 252 | data, 253 | headers: normalizeHeaders(headers), 254 | status, 255 | }; 256 | } 257 | 258 | function isError(error: unknown): error is NodeJS.ErrnoException { 259 | return error instanceof Error; 260 | } 261 | 262 | /** 263 | * Transforms the error response. 264 | * 265 | * @param error The Error object 266 | * @return {ApiError} 267 | */ 268 | function transformError(error: unknown): ApiError { 269 | let code, 270 | message = '', 271 | status, 272 | headers; 273 | 274 | if (axios.isAxiosError(error)) { 275 | if (error.response) { 276 | // The request was made and the server responded with a status code 277 | // that falls out of the range of 2xx 278 | const { data, statusText } = error.response; 279 | code = statusText; 280 | message = formatMessage(data); 281 | status = error.response.status; 282 | headers = normalizeHeaders(error.response.headers); 283 | } else if (error.request) { 284 | // The request was made but no response was received 285 | code = 'NO_RESPONSE'; 286 | message = 'No response from server'; 287 | } else { 288 | // Something happened in setting up the request that triggered an Error 289 | // The request was made but no response was received 290 | code = error.code; 291 | message = error.message; 292 | } 293 | } else if (isError(error)) { 294 | code = error.code; 295 | message = error.message; 296 | } 297 | 298 | return new ApiError( 299 | message, 300 | typeof code === 'string' ? code.toUpperCase().replace(/ /g, '_') : 'ERROR', 301 | status, 302 | headers, 303 | ); 304 | } 305 | 306 | function normalizeHeaders( 307 | headers: axios.AxiosResponse['headers'], 308 | ): HttpHeaders { 309 | // so that headers are not returned as AxiosHeaders 310 | return Object.fromEntries(Object.entries(headers || {})); 311 | } 312 | 313 | function formatMessage(message: unknown): string { 314 | // get rid of trailing newlines 315 | return typeof message === 'string' ? message.trim() : String(message); 316 | } 317 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from './client'; 2 | 3 | // Singleton 4 | export const swell = new Client(); 5 | 6 | export * from './client'; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "./dist", 5 | "declaration": true, 6 | "lib": ["es2020"], 7 | "module": "node16", 8 | "target": "es2020", 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "moduleResolution": "node16", 15 | "sourceMap": true 16 | }, 17 | "include": [ 18 | "./src/**/*.ts", 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | ] 23 | } 24 | --------------------------------------------------------------------------------