├── .nvmrc ├── .eslintignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .github ├── CODEOWNERS ├── workflows │ ├── dependabot-approve-and-request-merge.yaml │ ├── codeql.yaml │ ├── build.yaml │ ├── check.yaml │ ├── main.yaml │ ├── release.yaml │ └── failure-notification.yaml └── dependabot.yml ├── global.d.ts ├── .prettierrc ├── .contentful └── vault-secrets.yaml ├── vitest.config.js ├── tsconfig.json ├── src ├── async-token.ts ├── enforce-obj-path.ts ├── create-request-config.ts ├── freeze-sys.ts ├── index.ts ├── to-plain-object.ts ├── utils.ts ├── error-handler.ts ├── get-user-agent.ts ├── create-http-client.ts ├── rate-limit-throttle.ts ├── rate-limit.ts ├── create-default-options.ts └── types.ts ├── test └── unit │ ├── create-request-config-test.spec.ts │ ├── freeze-sys-test.spec.ts │ ├── to-plain-object.spec.ts │ ├── utils-test.spec.ts │ ├── mocks.ts │ ├── create-http-client-integration.spec.ts │ ├── user-agent-test.spec.ts │ ├── error-handler-test.spec.ts │ ├── rate-limit-throttle.spec.ts │ ├── rate-limit-test.spec.ts │ └── create-http-client-test.spec.ts ├── .eslintrc.cjs ├── catalog-info.yaml ├── LICENSE ├── README.md ├── .gitignore └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @contentful/team-developer-experience 2 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Process { 3 | browser: boolean 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /.contentful/vault-secrets.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | services: 3 | github-action: 4 | policies: 5 | - dependabot 6 | - semantic-release 7 | - packages-read 8 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "rootDir": "./src", 5 | "target": "esnext", 6 | "module": "Preserve", 7 | "moduleResolution": "bundler", 8 | "declarationDir": "dist/types", 9 | "declaration": true, 10 | "strict": true, 11 | "esModuleInterop": true 12 | }, 13 | "include": ["src/**/*", "global.d.ts"], 14 | } 15 | -------------------------------------------------------------------------------- /src/async-token.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance } from './types.js' 2 | 3 | export default function asyncToken(instance: AxiosInstance, getToken: () => Promise): void { 4 | instance.interceptors.request.use(function (config) { 5 | return getToken().then((accessToken) => { 6 | config.headers.set('Authorization', `Bearer ${accessToken}`) 7 | return config 8 | }) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/enforce-obj-path.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export default function enforceObjPath(obj: any, path: string): boolean { 3 | if (!(path in obj)) { 4 | const err = new Error() 5 | err.name = 'PropertyMissing' 6 | err.message = `Required property ${path} missing from: 7 | 8 | ${JSON.stringify(obj)} 9 | 10 | ` 11 | throw err 12 | } 13 | return true 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-approve-and-request-merge.yaml: -------------------------------------------------------------------------------- 1 | name: "dependabot approve-and-request-merge" 2 | 3 | on: pull_request_target 4 | 5 | jobs: 6 | worker: 7 | permissions: 8 | contents: write 9 | id-token: write 10 | pull-requests: write 11 | runs-on: ubuntu-latest 12 | if: github.actor == 'dependabot[bot]' 13 | steps: 14 | - uses: contentful/github-auto-merge@v2 15 | with: 16 | VAULT_URL: ${{ secrets.VAULT_URL }} -------------------------------------------------------------------------------- /test/unit/create-request-config-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest' 2 | 3 | import createRequestConfig from '../../src/create-request-config' 4 | 5 | it('Create request config', () => { 6 | const config = createRequestConfig({ 7 | query: { 8 | resolveLinks: true, 9 | }, 10 | }) 11 | 12 | expect(config.params).toBeDefined() 13 | // resolveLinks property is removed from query 14 | expect(config.params?.resolveLinks).not.toBeDefined() 15 | }) 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | overrides: [ 4 | { 5 | files: 'src/**/*.ts', 6 | parser: '@typescript-eslint/parser', 7 | plugins: ['@typescript-eslint'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier', 13 | ], 14 | rules: { 15 | '@typescript-eslint/no-explicit-any': 1, 16 | }, 17 | }, 18 | ], 19 | } -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: contentful-sdk-core 5 | description: | 6 | Core modules for the Contentful JS SDKs 7 | annotations: 8 | circleci.com/project-slug: github/contentful/contentful-sdk-core 9 | github.com/project-slug: contentful/contentful-sdk-core 10 | contentful.com/ci-alert-slack: prd-ecosystem-dx-bots 11 | contentful.com/service-tier: "4" 12 | tags: 13 | - tier-4 14 | spec: 15 | type: library 16 | lifecycle: production 17 | owner: group:team-developer-experience 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: 'npm' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | day: 'monday' 9 | open-pull-requests-limit: 10 10 | reviewers: 11 | - 'contentful/team-developer-experience' 12 | labels: 13 | - 'dependencies' 14 | commit-message: 15 | prefix: 'chore' 16 | include: 'scope' 17 | ignore: 18 | - dependency-name: "fast-copy" 19 | versions: 20 | - ">=3.0.0" 21 | cooldown: 22 | default-days: 15 23 | -------------------------------------------------------------------------------- /src/create-request-config.ts: -------------------------------------------------------------------------------- 1 | import copy from 'fast-copy' 2 | 3 | type Config = { 4 | params?: Record 5 | } 6 | 7 | /** 8 | * Creates request parameters configuration by parsing an existing query object 9 | * @private 10 | * @param {Object} query 11 | * @return {Object} Config object with `params` property, ready to be used in axios 12 | */ 13 | export default function createRequestConfig({ query }: { query: Record }): Config { 14 | const config: Config = {} 15 | delete query.resolveLinks 16 | config.params = copy(query) 17 | return config 18 | } 19 | -------------------------------------------------------------------------------- /src/freeze-sys.ts: -------------------------------------------------------------------------------- 1 | // copied from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze 2 | 3 | type FreezeObject = Record 4 | 5 | function deepFreeze(object: T): T { 6 | const propNames = Object.getOwnPropertyNames(object) 7 | 8 | for (const name of propNames) { 9 | const value = object[name] 10 | 11 | if (value && typeof value === 'object') { 12 | deepFreeze(value) 13 | } 14 | } 15 | 16 | return Object.freeze(object) 17 | } 18 | 19 | export default function freezeSys(obj: T): T { 20 | deepFreeze(obj.sys || {}) 21 | return obj 22 | } 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createHttpClient } from './create-http-client.js' 2 | export { default as createRequestConfig } from './create-request-config.js' 3 | export { default as enforceObjPath } from './enforce-obj-path.js' 4 | export { default as freezeSys } from './freeze-sys.js' 5 | export { default as getUserAgentHeader } from './get-user-agent.js' 6 | export { default as toPlainObject } from './to-plain-object.js' 7 | export { default as errorHandler } from './error-handler.js' 8 | export { default as createDefaultOptions } from './create-default-options.js' 9 | 10 | export type { AxiosInstance, CreateHttpClientParams, DefaultOptions } from './types.js' 11 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL Scan for GitHub Actions Workflows" 3 | 4 | on: 5 | push: 6 | branches: ['**'] 7 | 8 | jobs: 9 | analyze: 10 | name: Analyze GitHub Actions workflows 11 | runs-on: ubuntu-latest 12 | permissions: 13 | actions: read 14 | contents: read 15 | security-events: write 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: Initialize CodeQL 21 | uses: github/codeql-action/init@v4 22 | with: 23 | languages: actions 24 | 25 | - name: Run CodeQL Analysis 26 | uses: github/codeql-action/analyze@v4 27 | with: 28 | category: actions 29 | -------------------------------------------------------------------------------- /test/unit/freeze-sys-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest' 2 | 3 | import freezeSys from '../../src/freeze-sys' 4 | 5 | it('Freezes sys and child objects', () => { 6 | expect.assertions(2) 7 | const obj = { 8 | sys: { 9 | a: 1, 10 | b: { 11 | c: 2, 12 | }, 13 | }, 14 | } 15 | const frozen = freezeSys(obj) 16 | 17 | expect(() => { 18 | frozen.sys.a = 2 19 | }).toThrowErrorMatchingInlineSnapshot( 20 | `[TypeError: Cannot assign to read only property 'a' of object '#']`, 21 | ) 22 | 23 | expect(() => { 24 | frozen.sys.b.c = 3 25 | }).toThrowErrorMatchingInlineSnapshot( 26 | `[TypeError: Cannot assign to read only property 'c' of object '#']`, 27 | ) 28 | }) 29 | -------------------------------------------------------------------------------- /src/to-plain-object.ts: -------------------------------------------------------------------------------- 1 | import copy from 'fast-copy' 2 | 3 | /** 4 | * Mixes in a method to return just a plain object with no additional methods 5 | * @private 6 | * @param data - Any plain JSON response returned from the API 7 | * @return Enhanced object with toPlainObject method 8 | */ 9 | export default function toPlainObject, R = T>( 10 | data: T, 11 | ): T & { 12 | /** 13 | * Returns this entity as a plain JS object 14 | */ 15 | toPlainObject(): R 16 | } { 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-expect-error 19 | return Object.defineProperty(data, 'toPlainObject', { 20 | enumerable: false, 21 | configurable: false, 22 | writable: false, 23 | value: function () { 24 | return copy(this) 25 | }, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: '22' 21 | cache: 'npm' 22 | 23 | - name: Install latest npm 24 | run: npm install -g npm@latest 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build 30 | run: npm run build 31 | 32 | - name: Save Build folders 33 | uses: actions/cache/save@v4 34 | with: 35 | path: | 36 | dist 37 | key: build-cache-${{ github.run_id }}-${{ github.run_attempt }} -------------------------------------------------------------------------------- /test/unit/to-plain-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest' 2 | 3 | import toPlainObject from '../../src/to-plain-object' 4 | 5 | it('toPlainObject', () => { 6 | class TestClassObject { 7 | private name: string 8 | private nestedProp: any 9 | constructor(name: string) { 10 | this.name = name 11 | this.nestedProp = { 12 | int: 42, 13 | string: 'value', 14 | array: [0, 'hello'], 15 | } 16 | } 17 | 18 | testFunction() { 19 | return 'test function called' 20 | } 21 | } 22 | 23 | const obj = new TestClassObject('class object') 24 | const result = toPlainObject(obj) 25 | 26 | expect(obj instanceof TestClassObject).toBeTruthy() 27 | expect(obj).toBe(result) 28 | expect(typeof result.toPlainObject).toBe('function') 29 | expect(result).not.toBe(result.toPlainObject()) 30 | expect(result).toEqual(result.toPlainObject()) 31 | expect(obj).toEqual(result.toPlainObject()) 32 | }) 33 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import process from 'process' 2 | 3 | export function isNode(): boolean { 4 | /** 5 | * Polyfills of 'process' might set process.browser === true 6 | * 7 | * See: 8 | * https://github.com/webpack/node-libs-browser/blob/master/mock/process.js#L8 9 | * https://github.com/defunctzombie/node-process/blob/master/browser.js#L156 10 | **/ 11 | return typeof process !== 'undefined' && !process.browser 12 | } 13 | 14 | export function isReactNative(): boolean { 15 | return ( 16 | typeof window !== 'undefined' && 17 | 'navigator' in window && 18 | 'product' in window.navigator && 19 | window.navigator.product === 'ReactNative' 20 | ) 21 | } 22 | 23 | export function getNodeVersion(): string { 24 | return process.versions && process.versions.node ? `v${process.versions.node}` : process.version 25 | } 26 | 27 | export function getWindow(): Window { 28 | return window 29 | } 30 | 31 | export function noop(): undefined { 32 | return undefined 33 | } 34 | -------------------------------------------------------------------------------- /test/unit/utils-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, MockedObject, it, expect, describe } from 'vitest' 2 | import process from 'process' 3 | 4 | import { isNode, getNodeVersion } from '../../src/utils' 5 | 6 | describe('utils-test', () => { 7 | it('Detects node properly', () => { 8 | expect(isNode()).toEqual(true) 9 | }) 10 | 11 | it('Detects node properly with babel-polyfill', () => { 12 | vi.mock('process') 13 | const mockedProcess = process as MockedObject 14 | // @ts-expect-error 15 | mockedProcess.browser = true 16 | // detects non-node environment with babel-polyfill 17 | expect(isNode()).toEqual(false) 18 | // @ts-expect-error 19 | mockedProcess.browser = false 20 | }) 21 | 22 | it('Detects node version', () => { 23 | const version = getNodeVersion() 24 | expect( 25 | version.match( 26 | /v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?\b/, 27 | ), 28 | ).toBeTruthy() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Run Checks 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v5 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: '22' 21 | cache: 'npm' 22 | 23 | - name: Install latest npm 24 | run: npm install -g npm@latest 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Restore the build folders 30 | uses: actions/cache/restore@v4 31 | with: 32 | path: | 33 | dist 34 | key: build-cache-${{ github.run_id }}-${{ github.run_attempt }} 35 | 36 | - name: Run linter 37 | run: npm run lint 38 | 39 | - name: Check prettier formatting 40 | run: npm run prettier:check 41 | 42 | - name: Run tests 43 | run: npm run test:cover 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Contentful 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 | -------------------------------------------------------------------------------- /test/unit/mocks.ts: -------------------------------------------------------------------------------- 1 | import copy from 'fast-copy' 2 | 3 | const linkMock = { 4 | id: 'linkid', 5 | type: 'Link', 6 | linkType: 'linkType', 7 | } 8 | 9 | const sysMock = { 10 | type: 'Type', 11 | id: 'id', 12 | space: copy(linkMock), 13 | createdAt: 'createdatdate', 14 | updatedAt: 'updatedatdate', 15 | revision: 1, 16 | } 17 | 18 | const contentTypeMock = { 19 | sys: Object.assign(copy(sysMock), { 20 | type: 'ContentType', 21 | }), 22 | name: 'name', 23 | description: 'desc', 24 | displayField: 'displayfield', 25 | fields: [ 26 | { 27 | id: 'fieldid', 28 | name: 'fieldname', 29 | type: 'Text', 30 | localized: true, 31 | required: false, 32 | }, 33 | ], 34 | } 35 | 36 | const entryMock = { 37 | sys: Object.assign(copy(sysMock), { 38 | type: 'Entry', 39 | contentType: Object.assign(copy(linkMock), { linkType: 'ContentType' }), 40 | locale: 'locale', 41 | }), 42 | fields: { 43 | field1: 'str', 44 | }, 45 | } 46 | 47 | const assetMock = { 48 | sys: Object.assign(copy(sysMock), { 49 | type: 'Asset', 50 | locale: 'locale', 51 | }), 52 | fields: { 53 | field1: 'str', 54 | }, 55 | } 56 | 57 | const errorMock = { 58 | config: { 59 | url: 'requesturl', 60 | headers: {}, 61 | }, 62 | response: { 63 | status: 404, 64 | statusText: 'Not Found', 65 | data: {}, 66 | }, 67 | } 68 | 69 | export { linkMock, sysMock, contentTypeMock, entryMock, assetMock, errorMock } 70 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: ['**'] 8 | 9 | jobs: 10 | build: 11 | uses: ./.github/workflows/build.yaml 12 | 13 | check: 14 | needs: build 15 | uses: ./.github/workflows/check.yaml 16 | 17 | release: 18 | if: github.event_name == 'push' && contains(fromJSON('["refs/heads/master", "refs/heads/beta", "refs/heads/dev"]'), github.ref) 19 | needs: [build, check] 20 | permissions: 21 | contents: write 22 | id-token: write 23 | actions: read 24 | uses: ./.github/workflows/release.yaml 25 | secrets: 26 | VAULT_URL: ${{ secrets.VAULT_URL }} 27 | 28 | notify-failure: 29 | if: | 30 | always() && 31 | (needs.build.result == 'failure' || needs.check.result == 'failure' || needs.release.result == 'failure') && 32 | (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/alpha') 33 | needs: [build, check, release] 34 | permissions: 35 | contents: read 36 | issues: write 37 | uses: ./.github/workflows/failure-notification.yaml 38 | with: 39 | workflow_name: "Main CI Pipeline" 40 | job_name: ${{ needs.build.result == 'failure' && 'build' || needs.check.result == 'failure' && 'check' || needs.release.result == 'failure' && 'release' || 'unknown' }} 41 | failure_reason: "One or more jobs in the main CI pipeline failed. Check the workflow run for detailed error information." -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | VAULT_URL: 7 | required: true 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: write 15 | id-token: write # Required for OIDC trusted publishing 16 | actions: read 17 | 18 | steps: 19 | - name: 'Retrieve Secrets from Vault' 20 | id: vault 21 | uses: hashicorp/vault-action@v3.4.0 22 | with: 23 | url: ${{ secrets.VAULT_URL }} 24 | role: ${{ github.event.repository.name }}-github-action 25 | method: jwt 26 | path: github-actions 27 | exportEnv: false 28 | secrets: | 29 | github/token/${{ github.event.repository.name }}-semantic-release token | GITHUB_TOKEN; 30 | 31 | - name: Get Automation Bot User ID 32 | id: get-user-id 33 | run: echo "user-id=$(gh api "/users/contentful-automation[bot]" --jq .id)" >> "$GITHUB_OUTPUT" 34 | env: 35 | GITHUB_TOKEN: ${{ steps.vault.outputs.GITHUB_TOKEN }} 36 | 37 | - name: Setting up Git User Credentials 38 | run: | 39 | git config --global user.name 'contentful-automation[bot]' 40 | git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+contentful-automation[bot]@users.noreply.github.com' 41 | 42 | - name: Checkout code 43 | uses: actions/checkout@v5 44 | with: 45 | fetch-depth: 0 46 | 47 | - name: Setup Node.js 48 | uses: actions/setup-node@v6 49 | with: 50 | node-version: '22' 51 | cache: 'npm' 52 | 53 | - name: Install latest npm 54 | run: npm install -g npm@latest 55 | 56 | - name: Install dependencies 57 | run: npm ci 58 | 59 | - name: Restore the build folders 60 | uses: actions/cache/restore@v4 61 | with: 62 | path: | 63 | dist 64 | key: build-cache-${{ github.run_id }}-${{ github.run_attempt }} 65 | 66 | - name: Run Release 67 | run: | 68 | echo "Starting Semantic Release Process" 69 | echo "npm version: $(npm -v)" 70 | npm run semantic-release 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /src/error-handler.ts: -------------------------------------------------------------------------------- 1 | import isPlainObject from 'lodash/isPlainObject.js' 2 | import type { ContentfulErrorData } from './types.js' 3 | 4 | function obscureHeaders(config: any) { 5 | // Management, Delivery and Preview API tokens 6 | if (config?.headers?.['Authorization']) { 7 | const token = `...${config.headers['Authorization'].toString().substr(-5)}` 8 | config.headers['Authorization'] = `Bearer ${token}` 9 | } 10 | // Encoded Delivery or Preview token map for Cross-Space References 11 | if (config?.headers?.['X-Contentful-Resource-Resolution']) { 12 | const token = `...${config.headers['X-Contentful-Resource-Resolution'].toString().substr(-5)}` 13 | config.headers['X-Contentful-Resource-Resolution'] = token 14 | } 15 | } 16 | 17 | /** 18 | * Handles errors received from the server. Parses the error into a more useful 19 | * format, places it in an exception and throws it. 20 | * See https://www.contentful.com/developers/docs/references/errors/ 21 | * for more details on the data received on the errorResponse.data property 22 | * and the expected error codes. 23 | * @private 24 | */ 25 | export default function errorHandler(errorResponse: any): never { 26 | const { config, response } = errorResponse 27 | let errorName 28 | 29 | obscureHeaders(config) 30 | 31 | if (!isPlainObject(response) || !isPlainObject(config)) { 32 | throw errorResponse 33 | } 34 | 35 | const data = response?.data 36 | 37 | const errorData: ContentfulErrorData = { 38 | status: response?.status, 39 | statusText: response?.statusText, 40 | message: '', 41 | details: {}, 42 | } 43 | 44 | if (config && isPlainObject(config)) { 45 | errorData.request = { 46 | url: config.url, 47 | headers: config.headers, 48 | method: config.method, 49 | payloadData: config.data, 50 | } 51 | } 52 | if (data && typeof data === 'object') { 53 | if ('requestId' in data) { 54 | errorData.requestId = data.requestId || 'UNKNOWN' 55 | } 56 | if ('message' in data) { 57 | errorData.message = data.message || '' 58 | } 59 | if ('details' in data) { 60 | errorData.details = data.details || {} 61 | } 62 | errorName = data.sys?.id 63 | } 64 | 65 | const error = new Error() 66 | error.name = 67 | errorName && errorName !== 'Unknown' ? errorName : `${response?.status} ${response?.statusText}` 68 | 69 | try { 70 | error.message = JSON.stringify(errorData, null, ' ') 71 | } catch { 72 | error.message = errorData?.message ?? '' 73 | } 74 | throw error 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contentful-sdk-core 2 | 3 | ![MIT License](https://img.shields.io/badge/license-MIT-blue.svg) 4 | [![NPM Version](https://img.shields.io/npm/v/contentful-sdk-core.svg)](https://www.npmjs.com/package/contentful-sdk-core) 5 | [![npm downloads](https://img.shields.io/npm/dm/contentful-management.svg)](http://npm-stat.com/charts.html?package=contentful-management) 6 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 7 | [![semantic-release](https://img.shields.io/badge/%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 8 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 9 | 10 | > This package contains some core modules and utilities used by both the [contentful.js](https://github.com/contentful/contentful.js) and [contentful-management.js](https://github.com/contentful/contentful-management.js) SDKs. 11 | 12 | ## About 13 | 14 | [Contentful](https://www.contentful.com) provides a content infrastructure for digital teams to power content in websites, apps, and devices. Unlike a CMS, Contentful was built to integrate with the modern software stack. It offers a central hub for structured content, powerful management and delivery APIs, and a customizable web app that enable developers and content creators to ship digital products fastera. 15 | 16 | ## Installation 17 | 18 | ``` 19 | npm install --saveDev contentful-sdk-core 20 | ``` 21 | 22 | ## Use case 23 | 24 | This package contains some core modules and utilities used by both the [contentful.js](https://github.com/contentful/contentful.js) and [contentful-management.js](https://github.com/contentful/contentful-management.js) SDKs. 25 | 26 | ## Support 27 | 28 | This repository is compatible with Node.js version 18 and later. It exclusively provides an ECMAScript Module (ESM) variant, utilizing the `"type": "module"` declaration in `package.json`. Users are responsible for addressing any compatibility issues between ESM and CommonJS (CJS). 29 | 30 | ## Types 31 | 32 | TypeScript definitions for this repository are available through the `"types"` property in `package.json`. 33 | 34 | ## Development 35 | 36 | ### Create the default and the es-modules build: 37 | 38 | ``` 39 | npm run build 40 | ``` 41 | 42 | ### Run Tests: 43 | 44 | Run only the unit tests: 45 | 46 | ``` 47 | npm run test 48 | ``` 49 | 50 | Run unit tests including coverage report: 51 | 52 | ``` 53 | npm run test:cover 54 | ``` 55 | -------------------------------------------------------------------------------- /src/get-user-agent.ts: -------------------------------------------------------------------------------- 1 | import process from 'process' 2 | 3 | import { isNode, getNodeVersion, isReactNative, getWindow } from './utils.js' 4 | 5 | function getBrowserOS(): string | null { 6 | const win = getWindow() 7 | if (!win) { 8 | return null 9 | } 10 | const userAgent = win.navigator.userAgent 11 | // TODO: platform is deprecated. 12 | const platform = win.navigator.platform 13 | const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'] 14 | const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'] 15 | const iosPlatforms = ['iPhone', 'iPad', 'iPod'] 16 | 17 | if (macosPlatforms.indexOf(platform) !== -1) { 18 | return 'macOS' 19 | } else if (iosPlatforms.indexOf(platform) !== -1) { 20 | return 'iOS' 21 | } else if (windowsPlatforms.indexOf(platform) !== -1) { 22 | return 'Windows' 23 | } else if (/Android/.test(userAgent)) { 24 | return 'Android' 25 | } else if (/Linux/.test(platform)) { 26 | return 'Linux' 27 | } 28 | 29 | return null 30 | } 31 | 32 | type PlatformMap = Record 33 | 34 | function getNodeOS(): string | null { 35 | const platform = process.platform || 'linux' 36 | const version = process.version || '0.0.0' 37 | const platformMap: PlatformMap = { 38 | android: 'Android', 39 | aix: 'Linux', 40 | darwin: 'macOS', 41 | freebsd: 'Linux', 42 | linux: 'Linux', 43 | openbsd: 'Linux', 44 | sunos: 'Linux', 45 | win32: 'Windows', 46 | } 47 | if (platform in platformMap) { 48 | return `${platformMap[platform] || 'Linux'}/${version}` 49 | } 50 | return null 51 | } 52 | 53 | export default function getUserAgentHeader( 54 | sdk: string, 55 | application?: string, 56 | integration?: string, 57 | feature?: string, 58 | ): string { 59 | const headerParts = [] 60 | 61 | if (application) { 62 | headerParts.push(`app ${application}`) 63 | } 64 | 65 | if (integration) { 66 | headerParts.push(`integration ${integration}`) 67 | } 68 | 69 | if (feature) { 70 | headerParts.push('feature ' + feature) 71 | } 72 | 73 | headerParts.push(`sdk ${sdk}`) 74 | 75 | let platform = null 76 | try { 77 | if (isReactNative()) { 78 | platform = getBrowserOS() 79 | headerParts.push('platform ReactNative') 80 | } else if (isNode()) { 81 | platform = getNodeOS() 82 | headerParts.push(`platform node.js/${getNodeVersion()}`) 83 | } else { 84 | platform = getBrowserOS() 85 | headerParts.push('platform browser') 86 | } 87 | } catch (e) { 88 | platform = null 89 | } 90 | 91 | if (platform) { 92 | headerParts.push(`os ${platform}`) 93 | } 94 | 95 | return `${headerParts.filter((item) => item !== '').join('; ')};` 96 | } 97 | -------------------------------------------------------------------------------- /test/unit/create-http-client-integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, it, expect, describe } from 'vitest' 2 | 3 | import createHttpClient from '../../src/create-http-client' 4 | 5 | import axios from 'axios' 6 | import MockAdapter from 'axios-mock-adapter' 7 | 8 | const mock = new MockAdapter(axios) 9 | 10 | afterEach(() => { 11 | mock.reset() 12 | }) 13 | 14 | it('should retrieve token asynchronously', async () => { 15 | const instance = createHttpClient(axios, { 16 | accessToken: () => { 17 | return Promise.resolve('async-token') 18 | }, 19 | }) 20 | 21 | mock.onGet('/test-endpoint').replyOnce(200) 22 | 23 | expect(instance.defaults.headers.Authorization).not.toBeDefined() 24 | 25 | await instance.get('/test-endpoint') 26 | expect(mock.history.get[0].headers?.Authorization).toEqual('Bearer async-token') 27 | }) 28 | 29 | describe('custom interceptors', () => { 30 | it('adds new header asynchronously', async () => { 31 | const getHeaderAsync = () => { 32 | return Promise.resolve('custom-header-value') 33 | } 34 | 35 | const instance = createHttpClient(axios, { 36 | accessToken: 'token', 37 | onBeforeRequest: async (config) => { 38 | const value = await getHeaderAsync() 39 | config.headers['custom-header'] = value 40 | return config 41 | }, 42 | }) 43 | 44 | mock.onGet('/test-endpoint').replyOnce(200) 45 | 46 | await instance.get('/test-endpoint') 47 | expect(mock.history.get[0].headers?.['custom-header']).toEqual('custom-header-value') 48 | }) 49 | 50 | it('is able to intercept response codes', async () => { 51 | let accessToken = 'invalid-token' 52 | 53 | const refreshToken = () => { 54 | accessToken = 'valid-token' 55 | return Promise.resolve(accessToken) 56 | } 57 | 58 | const instance = createHttpClient(axios, { 59 | accessToken: () => Promise.resolve(accessToken), 60 | onError: async (error) => { 61 | const originalRequest = error.config 62 | if (error.response.status === 403 && !originalRequest._retry403) { 63 | originalRequest._retry403 = true 64 | const newToken = await refreshToken() 65 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 66 | // @ts-ignore 67 | axios.defaults.headers.Authorization = 'Bearer ' + newToken 68 | return instance(originalRequest) 69 | } 70 | return Promise.reject(error) 71 | }, 72 | }) 73 | 74 | mock.onGet('/test-endpoint').replyOnce(403) 75 | mock.onGet('/test-endpoint').replyOnce(200) 76 | 77 | await instance.get('/test-endpoint') 78 | 79 | expect(mock.history.get[0].headers?.Authorization).toEqual('Bearer invalid-token') 80 | expect(mock.history.get[1].headers?.Authorization).toEqual('Bearer valid-token') 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/create-http-client.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosStatic } from 'axios' 2 | import copy from 'fast-copy' 3 | 4 | import asyncToken from './async-token.js' 5 | import rateLimitRetry from './rate-limit.js' 6 | import rateLimitThrottle from './rate-limit-throttle.js' 7 | import type { AxiosInstance, CreateHttpClientParams } from './types.js' 8 | import createDefaultOptions from './create-default-options.js' 9 | 10 | function copyHttpClientParams(options: CreateHttpClientParams): CreateHttpClientParams { 11 | const copiedOptions = copy(options) 12 | // httpAgent and httpsAgent cannot be copied because they can contain private fields 13 | copiedOptions.httpAgent = options.httpAgent 14 | copiedOptions.httpsAgent = options.httpsAgent 15 | return copiedOptions 16 | } 17 | 18 | /** 19 | * Create pre-configured axios instance 20 | * @private 21 | * @param {AxiosStatic} axios - Axios library 22 | * @param {CreateHttpClientParams} options - Initialization parameters for the HTTP client 23 | * @return {AxiosInstance} Initialized axios instance 24 | */ 25 | export default function createHttpClient( 26 | axios: AxiosStatic, 27 | options: CreateHttpClientParams, 28 | ): AxiosInstance { 29 | const axiosOptions = createDefaultOptions(options) 30 | 31 | const instance = axios.create(axiosOptions) as AxiosInstance 32 | instance.httpClientParams = options 33 | 34 | /** 35 | * Creates a new axios instance with the same default base parameters as the 36 | * current one, and with any overrides passed to the newParams object 37 | * This is useful as the SDKs use dependency injection to get the axios library 38 | * and the version of the library comes from different places depending 39 | * on whether it's a browser build or a node.js build. 40 | * @private 41 | * @param {CreateHttpClientParams} newParams - Initialization parameters for the HTTP client 42 | * @return {AxiosInstance} Initialized axios instance 43 | */ 44 | instance.cloneWithNewParams = function ( 45 | newParams: Partial, 46 | ): AxiosInstance { 47 | return createHttpClient(axios, { 48 | ...copyHttpClientParams(options), 49 | ...newParams, 50 | }) 51 | } 52 | 53 | /** 54 | * Apply interceptors. 55 | * Please note that the order of interceptors is important 56 | */ 57 | 58 | if (options.onBeforeRequest) { 59 | instance.interceptors.request.use(options.onBeforeRequest) 60 | } 61 | 62 | if (typeof options.accessToken === 'function') { 63 | asyncToken(instance, options.accessToken) 64 | } 65 | 66 | if (options.throttle) { 67 | rateLimitThrottle(instance, options.throttle) 68 | } 69 | rateLimitRetry(instance, options.retryLimit) 70 | 71 | if (options.onError) { 72 | instance.interceptors.response.use((response) => response, options.onError) 73 | } 74 | 75 | return instance 76 | } 77 | -------------------------------------------------------------------------------- /src/rate-limit-throttle.ts: -------------------------------------------------------------------------------- 1 | import isString from 'lodash/isString.js' 2 | import pThrottle from 'p-throttle' 3 | 4 | import { AxiosInstance } from './types.js' 5 | import { noop } from './utils.js' 6 | 7 | type ThrottleType = 'auto' | string 8 | 9 | const PERCENTAGE_REGEX = /(?\d+)(%)/ 10 | 11 | function calculateLimit(type: ThrottleType, max = 7) { 12 | let limit = max 13 | 14 | if (PERCENTAGE_REGEX.test(type)) { 15 | const groups = type.match(PERCENTAGE_REGEX)?.groups 16 | if (groups && groups.value) { 17 | const percentage = parseInt(groups.value) / 100 18 | limit = Math.round(max * percentage) 19 | } 20 | } 21 | return Math.min(30, Math.max(1, limit)) 22 | } 23 | 24 | function createThrottle(limit: number, logger: (...args: any[]) => void) { 25 | logger('info', `Throttle request to ${limit}/s`) 26 | return pThrottle({ 27 | limit, 28 | interval: 1000, 29 | strict: false, 30 | }) 31 | } 32 | 33 | export default (axiosInstance: AxiosInstance, type: ThrottleType | number = 'auto') => { 34 | const { logHandler = noop } = axiosInstance.defaults 35 | let limit = isString(type) ? calculateLimit(type) : calculateLimit('auto', type) 36 | let throttle = createThrottle(limit, logHandler) 37 | let isCalculated = false 38 | 39 | let requestInterceptorId = axiosInstance.interceptors.request.use( 40 | (config) => { 41 | return throttle(() => config)() 42 | }, 43 | function (error) { 44 | return Promise.reject(error) 45 | }, 46 | ) 47 | 48 | const responseInterceptorId = axiosInstance.interceptors.response.use( 49 | (response) => { 50 | if ( 51 | !isCalculated && 52 | isString(type) && 53 | (type === 'auto' || PERCENTAGE_REGEX.test(type)) && 54 | response.headers && 55 | response.headers['x-contentful-ratelimit-second-limit'] 56 | ) { 57 | const rawLimit = parseInt(response.headers['x-contentful-ratelimit-second-limit']) 58 | const nextLimit = calculateLimit(type, rawLimit) 59 | 60 | if (nextLimit !== limit) { 61 | if (requestInterceptorId) { 62 | axiosInstance.interceptors.request.eject(requestInterceptorId) 63 | } 64 | 65 | limit = nextLimit 66 | 67 | throttle = createThrottle(nextLimit, logHandler) 68 | requestInterceptorId = axiosInstance.interceptors.request.use( 69 | (config) => { 70 | return throttle(() => config)() 71 | }, 72 | function (error) { 73 | return Promise.reject(error) 74 | }, 75 | ) 76 | } 77 | 78 | isCalculated = true 79 | } 80 | 81 | return response 82 | }, 83 | function (error) { 84 | return Promise.reject(error) 85 | }, 86 | ) 87 | 88 | return () => { 89 | axiosInstance.interceptors.request.eject(requestInterceptorId) 90 | axiosInstance.interceptors.response.eject(responseInterceptorId) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | # Docker 4 | *.dockerfile 5 | *.dockerignore 6 | 7 | # NPM config 8 | 9 | # Created by https://www.gitignore.io/api/node,windows,osx,linux,vim 10 | 11 | ### Linux ### 12 | *~ 13 | 14 | # temporary files which can be created if a process still has a handle open of a deleted file 15 | .fuse_hidden* 16 | 17 | # KDE directory preferences 18 | .directory 19 | 20 | # Linux trash folder which might appear on any partition or disk 21 | .Trash-* 22 | 23 | # .nfs files are created when an open file is removed but is still being accessed 24 | .nfs* 25 | 26 | ### Node ### 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # Runtime data 35 | pids 36 | *.pid 37 | *.seed 38 | *.pid.lock 39 | 40 | # Directory for instrumented libs generated by jscoverage/JSCover 41 | lib-cov 42 | 43 | # Coverage directory used by tools like istanbul 44 | coverage 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (http://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Typescript v1 declaration files 66 | typings/ 67 | 68 | # Editor files 69 | .idea 70 | 71 | # Optional npm cache directory 72 | .npm 73 | 74 | # Optional eslint cache 75 | .eslintcache 76 | 77 | # Optional REPL history 78 | .node_repl_history 79 | 80 | # Output of 'npm pack' 81 | *.tgz 82 | 83 | # Yarn Integrity file 84 | .yarn-integrity 85 | 86 | # dotenv environment variables file 87 | .env 88 | 89 | 90 | ### OSX ### 91 | *.DS_Store 92 | .AppleDouble 93 | .LSOverride 94 | 95 | # Icon must end with two \r 96 | Icon 97 | 98 | 99 | # Thumbnails 100 | ._* 101 | 102 | # Files that might appear in the root of a volume 103 | .DocumentRevisions-V100 104 | .fseventsd 105 | .Spotlight-V100 106 | .TemporaryItems 107 | .Trashes 108 | .VolumeIcon.icns 109 | .com.apple.timemachine.donotpresent 110 | 111 | # Directories potentially created on remote AFP share 112 | .AppleDB 113 | .AppleDesktop 114 | Network Trash Folder 115 | Temporary Items 116 | .apdisk 117 | 118 | ### Vim ### 119 | # swap 120 | [._]*.s[a-v][a-z] 121 | [._]*.sw[a-p] 122 | [._]s[a-v][a-z] 123 | [._]sw[a-p] 124 | # session 125 | Session.vim 126 | # temporary 127 | .netrwhist 128 | # auto-generated tag files 129 | tags 130 | 131 | ### Windows ### 132 | # Windows thumbnail cache files 133 | Thumbs.db 134 | ehthumbs.db 135 | ehthumbs_vista.db 136 | 137 | # Folder config file 138 | Desktop.ini 139 | 140 | # Recycle Bin used on file shares 141 | $RECYCLE.BIN/ 142 | 143 | # Windows Installer files 144 | *.cab 145 | *.msi 146 | *.msm 147 | *.msp 148 | 149 | # Windows shortcuts 150 | *.lnk 151 | 152 | .vscode/ 153 | 154 | # End of https://www.gitignore.io/api/node,windows,osx,linux,vim 155 | 156 | .tool-versions -------------------------------------------------------------------------------- /test/unit/user-agent-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, it, expect, Mocked } from 'vitest' 2 | 3 | import getUserAgent from '../../src/get-user-agent' 4 | import * as utils from '../../src/utils' 5 | 6 | const mockedUtils = utils as Mocked 7 | 8 | vi.mock('../../src/utils', () => ({ 9 | isNode: vi.fn().mockResolvedValue(true), 10 | isReactNative: vi.fn().mockReturnValue(false), 11 | getNodeVersion: vi.fn().mockReturnValue('v12.13.1'), 12 | getWindow: vi.fn().mockReturnValue({ 13 | navigator: { 14 | platform: 'MacIntel', 15 | userAgent: 16 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.67 Safari/537.36', 17 | }, 18 | }), 19 | })) 20 | 21 | const headerRegEx = /(app|sdk|platform|integration|os) \S+(\/\d+.\d+.\d+(-[\w\d-]+)?)?;/gim 22 | 23 | it('Parse node user agent correctly', () => { 24 | const userAgent = getUserAgent( 25 | 'contentful.js/1.0.0', 26 | 'myApplication/1.0.0', 27 | 'myIntegration/1.0.0', 28 | ) 29 | 30 | // detects node.js platform 31 | expect(userAgent.indexOf('platform node.js/') !== -1).toBeTruthy() 32 | // detected valid semver node version 33 | expect( 34 | userAgent.match( 35 | /node\.js\/\bv?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?\b/, 36 | ), 37 | ).toBeTruthy() 38 | }) 39 | 40 | it('Parse browser user agent correctly', () => { 41 | mockedUtils.isNode.mockReturnValue(false) 42 | 43 | const userAgent = getUserAgent( 44 | 'contentful.js/1.0.0', 45 | 'myApplication/1.0.0', 46 | 'myIntegration/1.0.0', 47 | ) 48 | 49 | expect(userAgent.match(headerRegEx)?.length).toEqual(5) 50 | expect(userAgent.indexOf('os macOS;') !== -1).toBeTruthy() 51 | expect(userAgent.indexOf('platform browser;') !== -1).toBeTruthy() 52 | }) 53 | 54 | it('Fail safely', () => { 55 | mockedUtils.isNode.mockReturnValue(false) 56 | // @ts-expect-error intententionally return broken window object 57 | mockedUtils.getWindow.mockReturnValue({}) 58 | 59 | const userAgent = getUserAgent( 60 | 'contentful.js/1.0.0', 61 | 'myApplication/1.0.0', 62 | 'myIntegration/1.0.0', 63 | ) 64 | expect(userAgent.match(headerRegEx)?.length).toEqual(3) 65 | // empty os 66 | expect(userAgent.indexOf('os') === -1).toBeTruthy() 67 | // empty browser platform 68 | expect(userAgent.indexOf('platform') === -1).toBeTruthy() 69 | }) 70 | 71 | it('Parse react native user agent correctly', () => { 72 | mockedUtils.isNode.mockReturnValue(false) 73 | mockedUtils.isReactNative.mockReturnValue(true) 74 | mockedUtils.getWindow.mockReturnValue({ 75 | // @ts-expect-error incomplete navigator object 76 | navigator: { 77 | platform: 'ReactNative', 78 | userAgent: 79 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.67 Safari/537.36', 80 | }, 81 | }) 82 | 83 | const userAgent = getUserAgent( 84 | 'contentful.js/1.0.0', 85 | 'myApplication/1.0.0', 86 | 'myIntegration/1.0.0', 87 | ) 88 | 89 | // consists of 4 parts since os is missing in mocked data 90 | expect(userAgent.match(headerRegEx)?.length).toEqual(4) 91 | // detects react native platform 92 | expect(userAgent.indexOf('platform ReactNative') !== -1).toBeTruthy() 93 | }) 94 | -------------------------------------------------------------------------------- /src/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { noop } from './utils.js' 2 | import type { AxiosInstance } from './types.js' 3 | 4 | const delay = (ms: number): Promise => 5 | new Promise((resolve) => { 6 | setTimeout(resolve, ms) 7 | }) 8 | 9 | const defaultWait = (attempts: number): number => { 10 | return Math.pow(Math.SQRT2, attempts) 11 | } 12 | 13 | export default function rateLimit(instance: AxiosInstance, maxRetry = 5): void { 14 | const { responseLogger = noop, requestLogger = noop } = instance.defaults 15 | 16 | instance.interceptors.request.use( 17 | function (config) { 18 | requestLogger(config) 19 | return config 20 | }, 21 | function (error) { 22 | requestLogger(error) 23 | return Promise.reject(error) 24 | }, 25 | ) 26 | 27 | instance.interceptors.response.use( 28 | function (response) { 29 | // we don't need to do anything here 30 | responseLogger(response) 31 | return response 32 | }, 33 | async function (error) { 34 | const { response } = error 35 | const { config } = error 36 | responseLogger(error) 37 | // Do not retry if it is disabled or no request config exists (not an axios error) 38 | if (!config || !instance.defaults.retryOnError) { 39 | return Promise.reject(error) 40 | } 41 | 42 | // Retried already for max attempts 43 | const doneAttempts = config.attempts || 1 44 | if (doneAttempts > maxRetry) { 45 | error.attempts = config.attempts 46 | return Promise.reject(error) 47 | } 48 | 49 | let retryErrorType = null 50 | let wait = defaultWait(doneAttempts) 51 | 52 | // Errors without response did not receive anything from the server 53 | if (!response) { 54 | retryErrorType = 'Connection' 55 | } else if (response.status >= 500 && response.status < 600) { 56 | // 5** errors are server related 57 | retryErrorType = `Server ${response.status}` 58 | } else if (response.status === 429) { 59 | // 429 errors are exceeded rate limit exceptions 60 | retryErrorType = 'Rate limit' 61 | // all headers are lowercased by axios https://github.com/mzabriskie/axios/issues/413 62 | if (response.headers && error.response.headers['x-contentful-ratelimit-reset']) { 63 | wait = response.headers['x-contentful-ratelimit-reset'] 64 | } 65 | } 66 | 67 | if (retryErrorType) { 68 | // convert to ms and add jitter 69 | wait = Math.floor(wait * 1000 + Math.random() * 200 + 500) 70 | instance.defaults.logHandler( 71 | 'warning', 72 | `${retryErrorType} error occurred. Waiting for ${wait} ms before retrying...`, 73 | ) 74 | 75 | // increase attempts counter 76 | config.attempts = doneAttempts + 1 77 | 78 | /* Somehow between the interceptor and retrying the request the httpAgent/httpsAgent gets transformed from an Agent-like object 79 | to a regular object, causing failures on retries after rate limits. Removing these properties here fixes the error, but retry 80 | requests still use the original http/httpsAgent property */ 81 | delete config.httpAgent 82 | delete config.httpsAgent 83 | 84 | return delay(wait).then(() => instance(config)) 85 | } 86 | return Promise.reject(error) 87 | }, 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/create-default-options.ts: -------------------------------------------------------------------------------- 1 | import { CreateHttpClientParams, DefaultOptions } from './types' 2 | import { AxiosRequestHeaders } from 'axios' 3 | import qs from 'qs' 4 | 5 | // Matches 'sub.host:port' or 'host:port' and extracts hostname and port 6 | // Also enforces toplevel domain specified, no spaces and no protocol 7 | const HOST_REGEX = /^(?!\w+:\/\/)([^\s:]+\.?[^\s:]+)(?::(\d+))?(?!:)$/ 8 | 9 | /** 10 | * Create default options 11 | * @private 12 | * @param {CreateHttpClientParams} options - Initialization parameters for the HTTP client 13 | * @return {DefaultOptions} options to pass to axios 14 | */ 15 | export default function createDefaultOptions(options: CreateHttpClientParams): DefaultOptions { 16 | const defaultConfig = { 17 | insecure: false as const, 18 | retryOnError: true as const, 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | logHandler: (level: string, data: any): void => { 21 | if (level === 'error' && data) { 22 | const title = [data.name, data.message].filter((a) => a).join(' - ') 23 | console.error(`[error] ${title}`) 24 | console.error(data) 25 | return 26 | } 27 | console.log(`[${level}] ${data}`) 28 | }, 29 | // Passed to axios 30 | headers: {} as AxiosRequestHeaders, 31 | httpAgent: false as const, 32 | httpsAgent: false as const, 33 | timeout: 30000, 34 | throttle: 0, 35 | basePath: '', 36 | adapter: undefined, 37 | maxContentLength: 1073741824, // 1GB 38 | maxBodyLength: 1073741824, // 1GB 39 | } 40 | const config = { 41 | ...defaultConfig, 42 | ...options, 43 | } 44 | 45 | if (!config.accessToken) { 46 | const missingAccessTokenError = new TypeError('Expected parameter accessToken') 47 | config.logHandler('error', missingAccessTokenError) 48 | throw missingAccessTokenError 49 | } 50 | 51 | // Construct axios baseURL option 52 | const protocol = config.insecure ? 'http' : 'https' 53 | const space = config.space ? `${config.space}/` : '' 54 | let hostname = config.defaultHostname 55 | let port: number | string = config.insecure ? 80 : 443 56 | if (config.host && HOST_REGEX.test(config.host)) { 57 | const parsed = config.host.split(':') 58 | if (parsed.length === 2) { 59 | ;[hostname, port] = parsed 60 | } else { 61 | hostname = parsed[0] 62 | } 63 | } 64 | 65 | // Ensure that basePath does start but not end with a slash 66 | if (config.basePath) { 67 | config.basePath = `/${config.basePath.split('/').filter(Boolean).join('/')}` 68 | } 69 | 70 | const baseURL = 71 | options.baseURL || `${protocol}://${hostname}:${port}${config.basePath}/spaces/${space}` 72 | 73 | if (!config.headers.Authorization && typeof config.accessToken !== 'function') { 74 | config.headers.Authorization = 'Bearer ' + config.accessToken 75 | } 76 | 77 | const axiosOptions: DefaultOptions = { 78 | // Axios 79 | baseURL, 80 | headers: config.headers, 81 | httpAgent: config.httpAgent, 82 | httpsAgent: config.httpsAgent, 83 | proxy: config.proxy, 84 | timeout: config.timeout, 85 | adapter: config.adapter, 86 | maxContentLength: config.maxContentLength, 87 | maxBodyLength: config.maxBodyLength, 88 | paramsSerializer: { 89 | serialize: (params) => { 90 | return qs.stringify(params) 91 | }, 92 | }, 93 | // Contentful 94 | logHandler: config.logHandler, 95 | responseLogger: config.responseLogger, 96 | requestLogger: config.requestLogger, 97 | retryOnError: config.retryOnError, 98 | } 99 | return axiosOptions 100 | } 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-sdk-core", 3 | "version": "0.0.0-determined-by-semantic-release", 4 | "description": "Core modules for the Contentful JS SDKs", 5 | "homepage": "https://www.contentful.com/developers/docs/javascript/", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "types": "dist/types/index.d.ts", 9 | "browser": { 10 | "process": "process/browser" 11 | }, 12 | "engines": { 13 | "node": ">=18" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/contentful/contentful-sdk-core.git" 18 | }, 19 | "author": "Contentful ", 20 | "license": "MIT", 21 | "scripts": { 22 | "clean": "rimraf coverage && rimraf dist", 23 | "build": "npm run clean && tsc --outDir dist", 24 | "lint": "eslint src test --ext '.ts'", 25 | "test": "vitest --run", 26 | "test:watch": "vitest", 27 | "test:cover": "vitest --run --coverage", 28 | "browser-coverage": "npm run test:cover && opener coverage/lcov-report/index.html", 29 | "prepublishOnly": "npm run build", 30 | "semantic-release": "semantic-release", 31 | "prettier": "prettier --write '**/*.{jsx,js,ts,tsx}'", 32 | "prettier:check": "prettier --check '**/*.{jsx,js,ts,tsx}'" 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "dependencies": { 38 | "fast-copy": "^3.0.2", 39 | "lodash": "^4.17.21", 40 | "p-throttle": "^6.1.0", 41 | "process": "^0.11.10", 42 | "qs": "^6.12.3" 43 | }, 44 | "devDependencies": { 45 | "@semantic-release/changelog": "^6.0.3", 46 | "@types/lodash": "^4.17.6", 47 | "@types/node": "^24.10.1", 48 | "@types/qs": "^6.9.10", 49 | "@typescript-eslint/eslint-plugin": "^5.11.0", 50 | "@typescript-eslint/parser": "^5.11.0", 51 | "@vitest/coverage-v8": "^2.0.2", 52 | "axios": "^1.12.2", 53 | "axios-mock-adapter": "^1.20.0", 54 | "cz-conventional-changelog": "^3.1.0", 55 | "eslint": "^8.57.0", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-config-standard": "^17.0.0", 58 | "eslint-plugin-standard": "^5.0.0", 59 | "husky": "^9.0.11", 60 | "lint-staged": "^15.2.5", 61 | "opener": "^1.4.1", 62 | "prettier": "^3.3.2", 63 | "rimraf": "^5.0.7", 64 | "semantic-release": "^25.0.2", 65 | "typescript": "^5.4.5", 66 | "vitest": "^2.0.2" 67 | }, 68 | "lint-staged": { 69 | "*.{js,jsx,ts,tsx,json}": [ 70 | "prettier --write", 71 | "eslint" 72 | ], 73 | "*.md": [ 74 | "prettier --write" 75 | ] 76 | }, 77 | "release": { 78 | "branches": [ 79 | "master", 80 | "next", 81 | { 82 | "name": "refactor/modern-esm-support", 83 | "channel": "9.x-alpha", 84 | "prerelease": true 85 | }, 86 | { 87 | "name": "beta", 88 | "channel": "beta", 89 | "prerelease": true 90 | }, 91 | { 92 | "name": "dev", 93 | "channel": "dev", 94 | "prerelease": true 95 | } 96 | ], 97 | "plugins": [ 98 | [ 99 | "@semantic-release/commit-analyzer", 100 | { 101 | "releaseRules": [ 102 | { 103 | "type": "build", 104 | "scope": "deps", 105 | "release": "patch" 106 | } 107 | ] 108 | } 109 | ], 110 | "@semantic-release/release-notes-generator", 111 | "@semantic-release/changelog", 112 | "@semantic-release/npm", 113 | "@semantic-release/github" 114 | ] 115 | }, 116 | "config": { 117 | "commitizen": { 118 | "path": "./node_modules/cz-conventional-changelog" 119 | } 120 | }, 121 | "optionalDependencies": { 122 | "@rollup/rollup-linux-x64-gnu": "^4.18.0" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHeaderValue, AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios' 2 | 3 | import type { 4 | AxiosInstance as OriginalAxiosInstance, 5 | AxiosRequestConfig, 6 | AxiosResponse, 7 | } from 'axios' 8 | 9 | export type DefaultOptions = AxiosRequestConfig & { 10 | logHandler: (level: string, data?: Error | string) => void 11 | responseLogger?: (response: AxiosResponse | Error) => unknown 12 | requestLogger?: (request: AxiosRequestConfig | Error) => unknown 13 | retryOnError?: boolean 14 | } 15 | 16 | export type AxiosInstance = OriginalAxiosInstance & { 17 | httpClientParams: CreateHttpClientParams 18 | cloneWithNewParams: (params: Partial) => AxiosInstance 19 | defaults: DefaultOptions 20 | } 21 | 22 | export type CreateHttpClientParams = { 23 | /** Access Token or an async function that returns Access Token */ 24 | accessToken: string | (() => Promise) 25 | 26 | /** Space ID */ 27 | space?: string 28 | 29 | /** 30 | * Requests will be made over http instead of the default https 31 | * @default false 32 | */ 33 | insecure?: boolean 34 | 35 | /** 36 | * API host 37 | */ 38 | host?: string 39 | /** HTTP agent for node */ 40 | httpAgent?: AxiosRequestConfig['httpAgent'] 41 | /** HTTPS agent for node */ 42 | httpsAgent?: AxiosRequestConfig['httpsAgent'] 43 | 44 | /** Axios adapter to handle requests */ 45 | adapter?: AxiosRequestConfig['adapter'] 46 | /** Axios proxy config */ 47 | proxy?: AxiosRequestConfig['proxy'] 48 | /** Axios fetch options */ 49 | fetchOptions?: AxiosRequestConfig['fetchOptions'] 50 | 51 | /** Gets called on every request triggered by the SDK, takes the axios request config as an argument */ 52 | requestLogger?: DefaultOptions['requestLogger'] 53 | /** Gets called on every response, takes axios response object as an argument */ 54 | responseLogger?: DefaultOptions['responseLogger'] 55 | 56 | /** Request interceptor */ 57 | onBeforeRequest?: ( 58 | value: InternalAxiosRequestConfig, 59 | ) => InternalAxiosRequestConfig | Promise 60 | 61 | /** Error handler */ 62 | onError?: (error: any) => any 63 | 64 | /** A log handler function to process given log messages & errors. Receives the log level (error, warning & info) and the actual log data (Error object or string). (Default can be found here: https://github.com/contentful/contentful-sdk-core/blob/master/lib/create-http-client.js) */ 65 | logHandler?: DefaultOptions['logHandler'] 66 | 67 | /** Optional additional headers */ 68 | headers?: AxiosRequestHeaders | Record 69 | 70 | defaultHostname?: string 71 | 72 | /** 73 | * If we should retry on errors and 429 rate limit exceptions 74 | * @default true 75 | */ 76 | retryOnError?: boolean 77 | 78 | /** 79 | * Optional number of retries before failure 80 | * @default 5 81 | */ 82 | retryLimit?: number 83 | 84 | /** 85 | * Optional number of milliseconds before the request times out. 86 | * @default 30000 87 | */ 88 | timeout?: number 89 | 90 | basePath?: string 91 | 92 | baseURL?: string 93 | 94 | /** 95 | * Optional maximum content length in bytes 96 | * @default 1073741824 i.e 1GB 97 | */ 98 | maxContentLength?: number 99 | 100 | /** 101 | * Optional maximum body length in bytes 102 | * @default 1073741824 i.e 1GB 103 | */ 104 | maxBodyLength?: number 105 | 106 | /** 107 | * Optional maximum number of requests per second (rate-limit) 108 | * @desc should represent the max of your current plan's rate limit 109 | * @default 0 = no throttling 110 | * @param 1-30 (fixed number of limit), 'auto' (calculated limit based on current tier), '0%' - '100%' (calculated % limit based on tier) 111 | */ 112 | throttle?: 'auto' | string | number 113 | 114 | /** 115 | * Optional how often the current request has been retried 116 | * @default 0 117 | */ 118 | attempt?: number 119 | } 120 | 121 | export type ContentfulErrorData = { 122 | status?: number 123 | statusText?: string 124 | requestId?: string 125 | message: string 126 | details: Record 127 | request?: Record 128 | sys?: { id?: string } 129 | } 130 | -------------------------------------------------------------------------------- /test/unit/error-handler-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect, describe } from 'vitest' 2 | 3 | import errorHandler from '../../src/error-handler' 4 | import { errorMock } from './mocks' 5 | import { cloneDeep } from 'lodash' 6 | 7 | const error: any = cloneDeep(errorMock) 8 | 9 | describe('A errorHandler', () => { 10 | // Best case scenario where an error is a known and expected situation and the 11 | // server returns an error with a JSON payload with all the information possible 12 | it('Throws well formed error with details from server', async () => { 13 | error.response.data = { 14 | sys: { 15 | id: 'SpecificError', 16 | type: 'Error', 17 | }, 18 | message: 'datamessage', 19 | requestId: 'requestid', 20 | details: 'errordetails', 21 | } 22 | 23 | try { 24 | errorHandler(error) 25 | } catch (err: any) { 26 | const parsedMessage = JSON.parse(err.message) 27 | expect(err.name).toBe('SpecificError') 28 | expect(parsedMessage.request.url).toBe('requesturl') 29 | expect(parsedMessage.message).toBe('datamessage') 30 | expect(parsedMessage.requestId).toBe('requestid') 31 | expect(parsedMessage.details).toBe('errordetails') 32 | } 33 | }) 34 | 35 | // Second best case scenario, where we'll still get a JSON payload from the server 36 | // but only with an Unknown error type and no additional details 37 | it('Throws unknown error received from server', async () => { 38 | error.response.data = { 39 | sys: { 40 | id: 'Unknown', 41 | type: 'Error', 42 | }, 43 | requestId: 'requestid', 44 | } 45 | error.response.status = 500 46 | error.response.statusText = 'Internal' 47 | 48 | try { 49 | errorHandler(error) 50 | } catch (err: any) { 51 | const parsedMessage = JSON.parse(err.message) 52 | expect(err.name).toBe('500 Internal') 53 | expect(parsedMessage.request.url).toBe('requesturl') 54 | expect(parsedMessage.requestId).toBe('requestid') 55 | } 56 | }) 57 | 58 | // Wurst case scenario, where we have no JSON payload and only HTTP status information 59 | it('Throws error without additional detail', async () => { 60 | error.response.status = 500 61 | error.response.statusText = 'Everything is on fire' 62 | 63 | try { 64 | errorHandler(error) 65 | } catch (err: any) { 66 | const parsedMessage = JSON.parse(err.message) 67 | expect(err.name).toBe('500 Everything is on fire') 68 | expect(parsedMessage.request.url).toBe('requesturl') 69 | } 70 | }) 71 | 72 | it('Obscures token in any error message', async () => { 73 | const responseError: any = cloneDeep(errorMock) 74 | responseError.config.headers = { 75 | Authorization: 'Bearer secret-token', 76 | } 77 | 78 | try { 79 | errorHandler(responseError) 80 | } catch (err: any) { 81 | const parsedMessage = JSON.parse(err.message) 82 | expect(parsedMessage.request.headers.Authorization).toBe('Bearer ...token') 83 | } 84 | 85 | const requestError: any = { 86 | config: { 87 | url: 'requesturl', 88 | headers: {}, 89 | }, 90 | data: {}, 91 | request: { 92 | status: 404, 93 | statusText: 'Not Found', 94 | }, 95 | } 96 | 97 | requestError.config.headers = { 98 | Authorization: 'Bearer secret-token', 99 | } 100 | 101 | try { 102 | errorHandler(requestError) 103 | } catch (err: any) { 104 | expect(err.config.headers.Authorization).toBe('Bearer ...token') 105 | } 106 | }) 107 | 108 | it('Obscures encoded cross-space reference tokens in any error message', async () => { 109 | const responseError: any = cloneDeep(errorMock) 110 | responseError.config.headers = { 111 | 'X-Contentful-Resource-Resolution': 'secret-token', 112 | } 113 | 114 | try { 115 | errorHandler(responseError) 116 | } catch (err: any) { 117 | const parsedMessage = JSON.parse(err.message) 118 | expect(parsedMessage.request.headers['X-Contentful-Resource-Resolution']).toBe('...token') 119 | } 120 | 121 | const requestError: any = { 122 | config: { 123 | url: 'requesturl', 124 | headers: {}, 125 | }, 126 | data: {}, 127 | request: { 128 | status: 404, 129 | statusText: 'Not Found', 130 | }, 131 | } 132 | 133 | requestError.config.headers = { 134 | 'X-Contentful-Resource-Resolution': 'secret-token', 135 | } 136 | 137 | try { 138 | errorHandler(requestError) 139 | } catch (err: any) { 140 | expect(err.config.headers['X-Contentful-Resource-Resolution']).toBe('...token') 141 | } 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /.github/workflows/failure-notification.yaml: -------------------------------------------------------------------------------- 1 | name: Create Issue on Workflow Failure 2 | 3 | permissions: 4 | contents: read 5 | issues: write 6 | 7 | on: 8 | workflow_call: 9 | inputs: 10 | workflow_name: 11 | description: 'Name of the failed workflow' 12 | required: true 13 | type: string 14 | job_name: 15 | description: 'Name of the failed job(s)' 16 | required: false 17 | type: string 18 | default: 'Unknown' 19 | failure_reason: 20 | description: 'Reason for the failure(s)' 21 | required: false 22 | type: string 23 | default: 'Unknown failure reason' 24 | 25 | jobs: 26 | create-failure-issue: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v5 31 | 32 | - name: Create Issue 33 | uses: actions/github-script@v7 34 | with: 35 | script: | 36 | const workflowName = '${{ inputs.workflow_name }}'; 37 | const jobName = '${{ inputs.job_name }}'; 38 | const failureReason = '${{ inputs.failure_reason }}'; 39 | const runUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`; 40 | const commitSha = context.sha; 41 | const commitUrl = `${context.payload.repository.html_url}/commit/${commitSha}`; 42 | const branch = context.ref.replace('refs/heads/', ''); 43 | const actor = context.actor; 44 | 45 | // Check if there's already an open issue for this workflow 46 | const existingIssues = await github.rest.issues.listForRepo({ 47 | owner: context.repo.owner, 48 | repo: context.repo.repo, 49 | state: 'open', 50 | labels: 'workflow-failure,' + workflowName.toLowerCase().replace(/\s+/g, '-') 51 | }); 52 | 53 | const title = `🚨 Workflow Failure: ${workflowName}`; 54 | const body = `## Workflow Failure Report 55 | 56 | **Workflow:** ${workflowName} 57 | **Job:** ${jobName} 58 | **Branch:** ${branch} 59 | **Commit:** [${commitSha.substring(0, 7)}](${commitUrl}) 60 | **Triggered by:** @${actor} 61 | **Run URL:** [View Failed Run](${runUrl}) 62 | 63 | ### Failure Details 64 | ${failureReason} 65 | 66 | ### Debugging Information 67 | - **Timestamp:** ${new Date().toISOString()} 68 | - **Repository:** ${context.payload.repository.full_name} 69 | - **Event:** ${context.eventName} 70 | 71 | ### Next Steps 72 | 1. Check the [workflow run logs](${runUrl}) for detailed error information 73 | 2. Review the changes in [commit ${commitSha.substring(0, 7)}](${commitUrl}) 74 | 3. Fix the issue and re-run the workflow 75 | 4. Close this issue once resolved 76 | 77 | --- 78 | *This issue was automatically created by the failure notification workflow.*`; 79 | 80 | // If no existing open issue, create a new one 81 | if (existingIssues.data.length === 0) { 82 | await github.rest.issues.create({ 83 | owner: context.repo.owner, 84 | repo: context.repo.repo, 85 | title: title, 86 | body: body, 87 | labels: [ 88 | 'workflow-failure', 89 | 'bug', 90 | workflowName.toLowerCase().replace(/\s+/g, '-'), 91 | 'automated' 92 | ] 93 | }); 94 | console.log(`Created new issue for ${workflowName} failure`); 95 | } else { 96 | console.log(`Issue already exists for ${workflowName} failure`); 97 | // Optionally add a comment to the existing issue 98 | await github.rest.issues.createComment({ 99 | owner: context.repo.owner, 100 | repo: context.repo.repo, 101 | issue_number: existingIssues.data[0].number, 102 | body: `## Additional Failure Report 103 | 104 | **New failure detected:** 105 | - **Job:** ${jobName} 106 | - **Commit:** [${commitSha.substring(0, 7)}](${commitUrl}) 107 | - **Run URL:** [View Failed Run](${runUrl}) 108 | - **Timestamp:** ${new Date().toISOString()} 109 | 110 | ${failureReason}` 111 | }); 112 | console.log(`Added comment to existing issue for ${workflowName} failure`); 113 | } -------------------------------------------------------------------------------- /test/unit/rate-limit-throttle.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, beforeEach, afterEach, it, expect, describe } from 'vitest' 2 | 3 | import axios from 'axios' 4 | import MockAdapter from 'axios-mock-adapter' 5 | import { AxiosInstance } from '../../src' 6 | import createHttpClient from '../../src/create-http-client' 7 | 8 | const logHandlerStub = vi.fn() 9 | 10 | function wait(ms = 1000) { 11 | return new Promise((resolve) => { 12 | setTimeout(resolve, ms) 13 | }) 14 | } 15 | 16 | function executeCalls(client: AxiosInstance, callsCount: number) { 17 | const requests: unknown[] = [] 18 | for (let i = 0; i < callsCount; i++) { 19 | requests.push(client.get('/throttled-call')) 20 | } 21 | return requests 22 | } 23 | 24 | describe('throttle to rate limit axios interceptor', () => { 25 | let mock: InstanceType 26 | 27 | beforeEach(() => { 28 | mock = new MockAdapter(axios) 29 | mock.onGet('/throttled-call').reply(200, null, { 'x-contentful-ratelimit-second-limit': 10 }) 30 | }) 31 | 32 | afterEach(() => { 33 | mock.reset() 34 | logHandlerStub.mockReset() 35 | }) 36 | 37 | async function expectCallsExecutedWithin( 38 | client: AxiosInstance, 39 | callsCount: number, 40 | duration: number, 41 | ) { 42 | // initial call to potentially update throttling settings 43 | client.get('/throttled-call') 44 | await wait(1000) 45 | 46 | const calls = executeCalls(client, callsCount) 47 | 48 | const start = Date.now() 49 | await Promise.all(calls) 50 | expect(mock.history.get).toHaveLength(callsCount + 1) 51 | 52 | expect(Date.now() - start).toBeLessThanOrEqual(duration) 53 | } 54 | 55 | function expectLogHandlerHasBeenCalled(limit: number, callCount: number) { 56 | expect(logHandlerStub).toBeCalledTimes(callCount) 57 | expect(logHandlerStub.mock.calls[callCount - 1][0]).toEqual('info') 58 | expect(logHandlerStub.mock.calls[callCount - 1][1]).toContain(`Throttle request to ${limit}/s`) 59 | } 60 | 61 | it('fires all requests directly', async () => { 62 | const client = createHttpClient(axios, { 63 | accessToken: 'token', 64 | logHandler: logHandlerStub, 65 | throttle: 0, 66 | }) 67 | await expectCallsExecutedWithin(client, 20, 100) 68 | expect(logHandlerStub).not.toHaveBeenCalled() 69 | }) 70 | 71 | it('throws on network errors', async () => { 72 | mock = new MockAdapter(axios) 73 | mock.onGet('/throttled-call').networkError() 74 | const client = createHttpClient(axios, { 75 | accessToken: 'token', 76 | logHandler: logHandlerStub, 77 | throttle: 'auto', 78 | retryOnError: false, 79 | }) 80 | 81 | await expect(client.get('/throttled-call')).rejects.toThrow('Network Error') 82 | }) 83 | 84 | it('fires limited requests per second', async () => { 85 | const client = createHttpClient(axios, { 86 | accessToken: 'token', 87 | logHandler: logHandlerStub, 88 | throttle: 3, 89 | }) 90 | await expectCallsExecutedWithin(client, 3, 1010) 91 | expectLogHandlerHasBeenCalled(3, 1) 92 | }) 93 | 94 | it('invalid argument defaults to 7/s', async () => { 95 | const client = createHttpClient(axios, { 96 | accessToken: 'token', 97 | logHandler: logHandlerStub, 98 | // @ts-ignore 99 | throttle: 'invalid', 100 | }) 101 | await expectCallsExecutedWithin(client, 7, 1010) 102 | expectLogHandlerHasBeenCalled(7, 1) 103 | }) 104 | 105 | it('invalid % argument defaults to 7/s', async () => { 106 | const client = createHttpClient(axios, { 107 | accessToken: 'token', 108 | logHandler: logHandlerStub, 109 | // @ts-ignore 110 | throttle: 'invalid%', 111 | }) 112 | await expectCallsExecutedWithin(client, 7, 1010) 113 | expectLogHandlerHasBeenCalled(7, 1) 114 | }) 115 | 116 | it('calculate limit based on response header', async () => { 117 | const client = createHttpClient(axios, { 118 | accessToken: 'token', 119 | logHandler: logHandlerStub, 120 | throttle: 'auto', 121 | }) 122 | await expectCallsExecutedWithin(client, 10, 1010) 123 | expectLogHandlerHasBeenCalled(10, 2) 124 | }) 125 | 126 | it.each([ 127 | { throttle: '30%', limit: 3, duration: 1010 }, 128 | { throttle: '50%', limit: 5, duration: 1010 }, 129 | { throttle: '70%', limit: 7, duration: 1010 }, 130 | ])( 131 | 'calculate $throttle limit based on response header', 132 | async ({ throttle, limit, duration }) => { 133 | const client = createHttpClient(axios, { 134 | accessToken: 'token', 135 | logHandler: logHandlerStub, 136 | // @ts-ignore 137 | throttle: throttle, 138 | }) 139 | await expectCallsExecutedWithin(client, limit, duration) 140 | expectLogHandlerHasBeenCalled(limit, 2) 141 | }, 142 | ) 143 | }) 144 | -------------------------------------------------------------------------------- /test/unit/rate-limit-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, afterEach, it, expect } from 'vitest' 2 | 3 | import axios from 'axios' 4 | import MockAdapter from 'axios-mock-adapter' 5 | 6 | import createHttpClient from '../../src/create-http-client' 7 | import { CreateHttpClientParams } from '../../src' 8 | 9 | const logHandlerStub = vi.fn() 10 | 11 | vi.setConfig({ testTimeout: 50000 }) 12 | 13 | const mock = new MockAdapter(axios) 14 | 15 | function setup(options: Partial = {}) { 16 | const client = createHttpClient(axios, { 17 | accessToken: 'token', 18 | logHandler: logHandlerStub, 19 | retryOnError: true, 20 | ...options, 21 | }) 22 | return { client } 23 | } 24 | 25 | function setupWithoutErrorRetry() { 26 | const client = createHttpClient(axios, { 27 | accessToken: 'token', 28 | logHandler: logHandlerStub, 29 | retryOnError: false, 30 | }) 31 | return { client } 32 | } 33 | 34 | function setupWithOneRetry() { 35 | const client = createHttpClient(axios, { 36 | accessToken: 'token', 37 | retryLimit: 1, 38 | retryOnError: true, 39 | logHandler: logHandlerStub, 40 | }) 41 | return { client } 42 | } 43 | 44 | afterEach(() => { 45 | logHandlerStub.mockReset() 46 | mock.reset() 47 | }) 48 | 49 | it('Retry on 429 after a duration >= rateLimit header', async () => { 50 | const { client } = setup() 51 | mock.onGet('/rate-limit-me').replyOnce(429, 'rateLimited', { 'x-contentful-ratelimit-reset': 4 }) 52 | mock.onGet('/rate-limit-me').replyOnce(200, 'works') 53 | const startTime = Date.now() 54 | expect.assertions(6) 55 | const response = await client.get('/rate-limit-me') 56 | 57 | expect(response.data).toBeDefined() 58 | expect(response.data).toEqual('works') 59 | expect(logHandlerStub).toBeCalledTimes(1) 60 | expect(logHandlerStub.mock.calls[0][0]).toEqual('warning') 61 | expect(logHandlerStub.mock.calls[0][1]).toContain('Rate limit error occurred.') 62 | expect(Date.now() - startTime >= 4000).toBeTruthy() 63 | }) 64 | 65 | it('Retry on 5** - multiple errors', async () => { 66 | const { client } = setup() 67 | mock.onGet('/rate-limit-me').replyOnce(500, 'Server Error', { 'x-contentful-request-id': 1 }) 68 | mock.onGet('/rate-limit-me').replyOnce(500, 'Server Error', { 'x-contentful-request-id': 1 }) 69 | mock.onGet('/rate-limit-me').replyOnce(200, 'works #1') 70 | mock 71 | .onGet('/rate-limit-me') 72 | .replyOnce(503, 'Another Server Error', { 'x-contentful-request-id': 2 }) 73 | mock.onGet('/rate-limit-me').replyOnce(200, 'works #2') 74 | expect.assertions(5) 75 | 76 | let response = await client.get('/rate-limit-me') 77 | expect(response.data).toBeDefined() 78 | expect(response.data).toEqual('works #1') 79 | 80 | const startTime = Date.now() 81 | response = await client.get('/rate-limit-me') 82 | // First error should not influence second errors retry delay 83 | expect(Date.now() - startTime <= 3000).toBeTruthy() 84 | expect(response.data).toBeDefined() 85 | expect(response.data).toEqual('works #2') 86 | }) 87 | 88 | it('Retry on network error', async () => { 89 | const { client } = setupWithOneRetry() 90 | mock.onGet('/rate-limit-me').networkError() 91 | 92 | try { 93 | await client.get('/rate-limit-me') 94 | } catch (error: any) { 95 | // logs two attempts, one initial and one retry 96 | expect(error.attempts).toEqual(2) 97 | } 98 | }) 99 | 100 | it('no retry when automatic handling flag is disabled', async () => { 101 | const { client } = setupWithoutErrorRetry() 102 | mock.onGet('/rate-limit-me').replyOnce(500, 'Mocked 500 Error', { 'x-contentful-request-id': 3 }) 103 | mock 104 | .onGet('/rate-limit-me') 105 | .replyOnce(200, 'would work but retry is disabled', { 'x-contentful-request-id': 4 }) 106 | 107 | expect.assertions(6) 108 | try { 109 | await client.get('/rate-limit-me') 110 | } catch (error: any) { 111 | expect(error.response.status).toEqual(500) 112 | expect(error.response.headers['x-contentful-request-id']).toEqual('3') 113 | expect(error.response.data).toEqual('Mocked 500 Error') 114 | expect(logHandlerStub).toHaveBeenCalledTimes(0) 115 | expect(error.message).toEqual('Request failed with status code 500') 116 | expect(error.attempts).not.toBeDefined() 117 | } 118 | }) 119 | 120 | it('Should Fail if it hits maxRetries', async () => { 121 | const { client } = setupWithOneRetry() 122 | mock.onGet('/error').replyOnce(500, 'error attempt #1', { 'x-contentful-request-id': 4 }) 123 | mock.onGet('/error').replyOnce(501, 'error attempt #2', { 'x-contentful-request-id': 4 }) 124 | mock.onGet('/error').replyOnce(200, 'should not be there', { 'x-contentful-request-id': 4 }) 125 | 126 | expect.assertions(5) 127 | try { 128 | await client.get('/error') 129 | } catch (error: any) { 130 | // returned error since maxRetries was reached 131 | expect(error.response.data).toEqual('error attempt #2') 132 | expect(logHandlerStub).toHaveBeenCalledTimes(1) 133 | expect(logHandlerStub.mock.calls[0][0]).toEqual('warning') 134 | expect(error.message).toEqual('Request failed with status code 501') 135 | expect(error.attempts).toEqual(2) 136 | } 137 | }) 138 | 139 | it('Retry on responses when X-Contentful-Request-Id header is missing', async () => { 140 | const { client } = setupWithOneRetry() 141 | mock.onGet('/error').replyOnce(500, 'error attempt') 142 | mock.onGet('/error').replyOnce(500, 'error attempt 2') 143 | mock.onGet('/error').replyOnce(200, 'works') 144 | 145 | expect.assertions(5) 146 | 147 | try { 148 | await client.get('/error') 149 | } catch (error: any) { 150 | expect(error.response.data).toEqual('error attempt 2') 151 | expect(logHandlerStub).toHaveBeenCalledTimes(1) 152 | expect(logHandlerStub.mock.calls[0][0]).toEqual('warning') 153 | expect(error.message).toEqual('Request failed with status code 500') 154 | expect(error.attempts).toEqual(2) 155 | } 156 | }) 157 | 158 | it('Rejects errors with strange status codes', async () => { 159 | const { client } = setup() 160 | mock.onGet('/error').replyOnce(765, 'error attempt') 161 | mock.onGet('/error').replyOnce(200, 'works') 162 | 163 | expect.assertions(2) 164 | try { 165 | await client.get('/error') 166 | } catch (error: any) { 167 | expect(error.response.data).toEqual('error attempt') 168 | // did not log anything 169 | expect(logHandlerStub).toBeCalledTimes(0) 170 | } 171 | }) 172 | 173 | it('Preserves URI query parameters between retries', async () => { 174 | const { client } = setup() 175 | const uri = '/entries?content_type=B&fields.id=1' 176 | 177 | mock.onGet(uri).replyOnce(429, 'Rate Limit') 178 | mock.onGet(uri).replyOnce(429, 'Rate Limit') 179 | mock.onGet(uri).replyOnce(200, 'Success') 180 | 181 | expect.assertions(5) 182 | 183 | const response = await client.get(uri) 184 | expect(response.status).toEqual(200) 185 | 186 | expect(mock.history.get.length).toBe(3) 187 | for (const request of mock.history.get) { 188 | expect(request.url).toEqual(uri) 189 | } 190 | }) 191 | -------------------------------------------------------------------------------- /test/unit/create-http-client-test.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, beforeEach, it, expect } from 'vitest' 2 | 3 | import createHttpClient from '../../src/create-http-client' 4 | 5 | import axios, { AxiosAdapter } from 'axios' 6 | import MockAdapter from 'axios-mock-adapter' 7 | 8 | vi.mock('axios') 9 | 10 | vi.mock('../../src/rate-limit') 11 | vi.mock('../../src/rate-limit-throttle') 12 | 13 | const logHandlerStub = vi.fn() 14 | 15 | const mock = new MockAdapter(axios) 16 | 17 | beforeEach(() => { 18 | // @ts-expect-error No need to instantiate a complete axios instance for the mock. 19 | vi.spyOn(axios, 'create').mockReturnValue({}) 20 | }) 21 | 22 | afterEach(() => { 23 | vi.mocked(axios).mockReset() 24 | mock.reset() 25 | logHandlerStub.mockReset() 26 | }) 27 | 28 | it('Calls axios with expected default URL', () => { 29 | createHttpClient(axios, { 30 | accessToken: 'clientAccessToken', 31 | space: 'clientSpaceId', 32 | defaultHostname: 'defaulthost', 33 | logHandler: logHandlerStub, 34 | }) 35 | 36 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 37 | expect(callConfig?.baseURL).toEqual('https://defaulthost:443/spaces/clientSpaceId/') 38 | expect(logHandlerStub).not.toHaveBeenCalled() 39 | }) 40 | 41 | it('Calls axios based on passed host', () => { 42 | createHttpClient(axios, { 43 | accessToken: 'clientAccessToken', 44 | host: 'contentful.com:8080', 45 | logHandler: logHandlerStub, 46 | }) 47 | 48 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 49 | expect(callConfig?.baseURL).toEqual('https://contentful.com:8080/spaces/') 50 | expect(logHandlerStub).not.toHaveBeenCalled() 51 | }) 52 | 53 | it('Calls axios based on passed host with insecure flag', () => { 54 | createHttpClient(axios, { 55 | accessToken: 'clientAccessToken', 56 | host: 'contentful.com:321', 57 | insecure: true, 58 | logHandler: logHandlerStub, 59 | }) 60 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 61 | expect(callConfig?.baseURL).toEqual('http://contentful.com:321/spaces/') 62 | expect(logHandlerStub).not.toHaveBeenCalled() 63 | }) 64 | 65 | it('Calls axios based on passed hostname with insecure flag', () => { 66 | createHttpClient(axios, { 67 | accessToken: 'clientAccessToken', 68 | host: 'contentful.com', 69 | insecure: true, 70 | logHandler: logHandlerStub, 71 | }) 72 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 73 | expect(callConfig?.baseURL).toEqual('http://contentful.com:80/spaces/') 74 | expect(logHandlerStub).not.toHaveBeenCalled() 75 | }) 76 | 77 | it('Calls axios based on passed headers', () => { 78 | createHttpClient(axios, { 79 | accessToken: 'clientAccessToken', 80 | headers: { 81 | 'X-Custom-Header': 'example', 82 | Authorization: 'Basic customAuth', 83 | }, 84 | }) 85 | 86 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 87 | expect(callConfig?.baseURL) 88 | // @ts-ignore 89 | expect(callConfig?.headers?.['X-Custom-Header']).toEqual('example') 90 | // @ts-ignore 91 | expect(callConfig?.headers?.Authorization).toEqual('Basic customAuth') 92 | }) 93 | 94 | it('Calls axios with request/response logger', () => { 95 | const requestLoggerStub = vi.fn() 96 | const responseLoggerStub = vi.fn() 97 | createHttpClient(axios, { 98 | accessToken: 'clientAccessToken', 99 | host: 'contentful.com', 100 | insecure: true, 101 | requestLogger: requestLoggerStub, 102 | responseLogger: responseLoggerStub, 103 | }) 104 | 105 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 106 | expect(callConfig?.baseURL).toEqual('http://contentful.com:80/spaces/') 107 | expect(requestLoggerStub).not.toHaveBeenCalled() 108 | expect(responseLoggerStub).not.toHaveBeenCalled() 109 | }) 110 | 111 | it('Fails with missing access token', () => { 112 | try { 113 | // @ts-expect-error expect access token not to be passed 114 | createHttpClient(axios, { 115 | logHandler: logHandlerStub, 116 | }) 117 | } catch (err: any) { 118 | expect(err instanceof TypeError).toBeTruthy() 119 | expect(err.message).toEqual('Expected parameter accessToken') 120 | expect(logHandlerStub).toHaveBeenCalledTimes(1) 121 | expect(logHandlerStub.mock.calls[0][0]).toEqual('error') 122 | expect(logHandlerStub.mock.calls[0][1].message).toEqual('Expected parameter accessToken') 123 | expect(logHandlerStub.mock.calls[0][1] instanceof TypeError).toBeTruthy() 124 | } 125 | }) 126 | 127 | it('Calls axios based on passed hostname with basePath', () => { 128 | createHttpClient(axios, { 129 | accessToken: 'clientAccessToken', 130 | host: 'some.random.example.com', 131 | basePath: '/foo/bar', 132 | }) 133 | 134 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 135 | expect(callConfig?.baseURL).toEqual('https://some.random.example.com:443/foo/bar/spaces/') 136 | expect(logHandlerStub).not.toHaveBeenCalled() 137 | }) 138 | 139 | it('Calls axios based on passed hostname with invalid basePath and fixes the invalid one', () => { 140 | createHttpClient(axios, { 141 | accessToken: 'clientAccessToken', 142 | host: 'some.random.example.com', 143 | basePath: 'foo/bar', 144 | }) 145 | 146 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 147 | expect(callConfig?.baseURL).toEqual('https://some.random.example.com:443/foo/bar/spaces/') 148 | expect(logHandlerStub).not.toHaveBeenCalled() 149 | }) 150 | 151 | it('Can change the adapter axios uses', () => { 152 | const testAdapter: AxiosAdapter = function myAdapter(config) { 153 | return new Promise(function (resolve) { 154 | const response = { 155 | data: 'Adapter was used', 156 | status: 200, 157 | statusText: 'request.statusText', 158 | headers: {}, 159 | config: config, 160 | request: undefined, 161 | } 162 | resolve(response) 163 | }) 164 | } 165 | 166 | const instance = createHttpClient(axios, { 167 | accessToken: 'clientAccessToken', 168 | space: 'clientSpaceId', 169 | defaultHostname: 'defaulthost', 170 | logHandler: logHandlerStub, 171 | adapter: testAdapter, 172 | }) 173 | 174 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 175 | expect(callConfig?.baseURL).toEqual('https://defaulthost:443/spaces/clientSpaceId/') 176 | expect(logHandlerStub).not.toHaveBeenCalled() 177 | expect(instance.httpClientParams.adapter).toEqual(testAdapter) 178 | }) 179 | 180 | it('Can change to the fetch adapter with fetch options', () => { 181 | const instance = createHttpClient(axios, { 182 | accessToken: 'clientAccessToken', 183 | space: 'clientSpaceId', 184 | defaultHostname: 'defaulthost', 185 | logHandler: logHandlerStub, 186 | adapter: 'fetch', 187 | fetchOptions: { cache: 'no-cache' }, 188 | }) 189 | 190 | const [callConfig] = vi.mocked(axios.create).mock.calls[0] 191 | expect(callConfig?.baseURL).toEqual('https://defaulthost:443/spaces/clientSpaceId/') 192 | expect(logHandlerStub).not.toHaveBeenCalled() 193 | expect(instance.httpClientParams.adapter).toEqual('fetch') 194 | expect(instance.httpClientParams.fetchOptions).toEqual({ cache: 'no-cache' }) 195 | }) 196 | --------------------------------------------------------------------------------