diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 3abded92..00000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,13 +0,0 @@ -# https://dependabot.com/docs/config-file/#dependabot-config-files -version: 1 -update_configs: - - package_manager: "javascript" - directory: "/" - update_schedule: "daily" - automerged_updates: - - match: - dependency_type: "development" - update_type: "all" - - match: - dependency_type: "production" - update_type: "in_range" diff --git a/package.json b/package.json index f7b04af6..7be2a3aa 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "fast-copy": "^2.1.0", + "lodash": "^4.17.21", "qs": "^6.9.4" }, "devDependencies": { @@ -59,12 +60,15 @@ "@semantic-release/github": "^7.2.3", "@semantic-release/npm": "^7.1.3", "@semantic-release/release-notes-generator": "^9.0.3", + "@types/chai": "^4.2.21", + "@types/lodash": "^4.14.172", "@types/qs": "^6.9.5", - "@typescript-eslint/eslint-plugin": "4.28.5", - "@typescript-eslint/parser": "4.29.0", + "@typescript-eslint/eslint-plugin": "4.31.0", + "@typescript-eslint/parser": "4.31.0", "axios": "^0.21.0", "axios-mock-adapter": "^1.15.0", "babel-eslint": "^10.1.0", + "chai": "^4.3.4", "core-js": "^3.8.0", "cz-conventional-changelog": "^3.1.0", "eslint": "^7.2.0", diff --git a/src/error-handler.ts b/src/error-handler.ts new file mode 100644 index 00000000..cc24e9b5 --- /dev/null +++ b/src/error-handler.ts @@ -0,0 +1,77 @@ +import { isPlainObject } from 'lodash' +import { AxiosError } from 'axios' + +/** + * Handles errors received from the server. Parses the error into a more useful + * format, places it in an exception and throws it. + * See https://www.contentful.com/developers/docs/references/errors/ + * for more details on the data received on the errorResponse.data property + * and the expected error codes. + * @private + */ +export default function errorHandler(errorResponse: AxiosError): never { + const { config, response } = errorResponse + let errorName + + // Obscure the Management token + if (config && config.headers && config.headers['Authorization']) { + const token = `...${config.headers['Authorization'].substr(-5)}` + config.headers['Authorization'] = `Bearer ${token}` + } + + if (!isPlainObject(response) || !isPlainObject(config)) { + throw errorResponse + } + + const data = response?.data + + const errorData: { + status?: number + statusText?: string + requestId?: string + message: string + details: Record + request?: Record + } = { + status: response?.status, + statusText: response?.statusText, + message: '', + details: {}, + } + + if (isPlainObject(config)) { + errorData.request = { + url: config.url, + headers: config.headers, + method: config.method, + payloadData: config.data, + } + } + if (data && isPlainObject(data)) { + if ('requestId' in data) { + errorData.requestId = data.requestId || 'UNKNOWN' + } + if ('message' in data) { + errorData.message = data.message || '' + } + if ('details' in data) { + errorData.details = data.details || {} + } + if ('sys' in data) { + if ('id' in data.sys) { + errorName = data.sys.id + } + } + } + + const error = new Error() + error.name = + errorName && errorName !== 'Unknown' ? errorName : `${response?.status} ${response?.statusText}` + + try { + error.message = JSON.stringify(errorData, null, ' ') + } catch { + error.message = errorData?.message ?? '' + } + throw error +} diff --git a/src/index.ts b/src/index.ts index 7ec0fc0e..33fd46ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,6 @@ export { default as enforceObjPath } from './enforce-obj-path' export { default as freezeSys } from './freeze-sys' export { default as getUserAgentHeader } from './get-user-agent' export { default as toPlainObject } from './to-plain-object' +export { default as errorHandler } from './error-handler' export type { AxiosInstance, CreateHttpClientParams } from './types' diff --git a/test/unit/error-handler-test.spec.ts b/test/unit/error-handler-test.spec.ts new file mode 100644 index 00000000..20f7df5c --- /dev/null +++ b/test/unit/error-handler-test.spec.ts @@ -0,0 +1,115 @@ +import errorHandler from '../../src/error-handler' +import { errorMock } from './mocks' +import { expect } from 'chai' +import cloneDeep from 'lodash/cloneDeep' + +const error: any = cloneDeep(errorMock) + +describe('A errorHandler', () => { + // Best case scenario where an error is a known and expected situation and the + // server returns an error with a JSON payload with all the information possible + it('Throws well formed error with details from server', async () => { + error.response.data = { + sys: { + id: 'SpecificError', + type: 'Error', + }, + message: 'datamessage', + requestId: 'requestid', + details: 'errordetails', + } + + try { + errorHandler(error) + } catch (err) { + const parsedMessage = JSON.parse(err.message) + expect(err.name).equals('SpecificError', 'error name') + expect(parsedMessage.request.url).equals('requesturl', 'request url') + expect(parsedMessage.message).equals('datamessage', 'error payload message') + expect(parsedMessage.requestId).equals('requestid', 'request id') + expect(parsedMessage.details).equals('errordetails', 'error payload details') + } + }) + + // Second best case scenario, where we'll still get a JSON payload from the server + // but only with an Unknown error type and no additional details + it('Throws unknown error received from server', async () => { + error.response.data = { + sys: { + id: 'Unknown', + type: 'Error', + }, + requestId: 'requestid', + } + error.response.status = 500 + error.response.statusText = 'Internal' + + try { + errorHandler(error) + } catch (err) { + const parsedMessage = JSON.parse(err.message) + expect(err.name).equals('500 Internal', 'error name defaults to status code and text') + expect(parsedMessage.request.url).equals('requesturl', 'request url') + expect(parsedMessage.requestId).equals('requestid', 'request id') + } + }) + + // Wurst case scenario, where we have no JSON payload and only HTTP status information + it('Throws error without additional detail', async () => { + error.response.status = 500 + error.response.statusText = 'Everything is on fire' + + try { + errorHandler(error) + } catch (err) { + const parsedMessage = JSON.parse(err.message) + expect(err.name).equals( + '500 Everything is on fire', + 'error name defaults to status code and text' + ) + expect(parsedMessage.request.url).equals('requesturl', 'request url') + } + }) + + it('Obscures management token in any error message', async () => { + const responseError: any = cloneDeep(errorMock) + responseError.config.headers = { + Authorization: 'Bearer secret-management-token', + } + + try { + errorHandler(responseError) + } catch (err) { + const parsedMessage = JSON.parse(err.message) + expect(parsedMessage.request.headers.Authorization).equals( + 'Bearer ...token', + 'Obscures management token' + ) + } + + const requestError: any = { + config: { + url: 'requesturl', + headers: {}, + }, + data: {}, + request: { + status: 404, + statusText: 'Not Found', + }, + } + + requestError.config.headers = { + Authorization: 'Bearer secret-management-token', + } + + try { + errorHandler(requestError) + } catch (err) { + expect(err.config.headers.Authorization).equals( + 'Bearer ...token', + 'Obscures management token' + ) + } + }) +}) diff --git a/test/unit/mocks.ts b/test/unit/mocks.ts index fc2c25bd..f38fa0c3 100644 --- a/test/unit/mocks.ts +++ b/test/unit/mocks.ts @@ -54,4 +54,16 @@ const assetMock = { }, } -export { linkMock, sysMock, contentTypeMock, entryMock, assetMock } +const errorMock = { + config: { + url: 'requesturl', + headers: {}, + }, + response: { + status: 404, + statusText: 'Not Found', + data: {}, + }, +} + +export { linkMock, sysMock, contentTypeMock, entryMock, assetMock, errorMock }