From 569d2126026dd1cfc58610f8bf1037e511b3898f Mon Sep 17 00:00:00 2001 From: Nick Watson Date: Tue, 7 Nov 2023 19:18:12 +0000 Subject: [PATCH] feat: add error handling for json api errors --- src/constants.ts | 5 +- src/http.ts | 60 ++++++++++++------ src/interfaces/json-api.ts | 9 +++ src/needle.ts | 7 ++- src/utils/httpUtils.ts | 38 ++++++++++++ tests/http.spec.ts | 123 ++++++++++++++++++++++++++++++++++++- tests/httpUtils.spec.ts | 80 +++++++++++++++++++++++- 7 files changed, 299 insertions(+), 23 deletions(-) create mode 100644 src/interfaces/json-api.ts diff --git a/src/constants.ts b/src/constants.ts index 0af79e6f..2b519225 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,6 +35,7 @@ export enum ErrorCodes { notFound = 404, bigPayload = 413, connectionRefused = 421, + unprocessableEntity = 422, dnsNotFound = 452, serverError = 500, badGateway = 502, @@ -63,6 +64,7 @@ export const DEFAULT_ERROR_MESSAGES: { [P in ErrorCodes]: string } = { [ErrorCodes.unauthorizedBundleAccess]: 'Unauthorized access to requested bundle analysis', [ErrorCodes.notFound]: 'Not found', [ErrorCodes.bigPayload]: `Payload too large (max is ${MAX_PAYLOAD}b)`, + [ErrorCodes.unprocessableEntity]: 'Unable to process analysis', }; export type GenericErrorTypes = @@ -71,4 +73,5 @@ export type GenericErrorTypes = | ErrorCodes.serviceUnavailable | ErrorCodes.timeout | ErrorCodes.connectionRefused - | ErrorCodes.dnsNotFound; + | ErrorCodes.dnsNotFound + | ErrorCodes.unprocessableEntity; diff --git a/src/http.ts b/src/http.ts index 88578139..41376b0d 100644 --- a/src/http.ts +++ b/src/http.ts @@ -3,26 +3,28 @@ import pick from 'lodash.pick'; import { gzip } from 'zlib'; import { promisify } from 'util'; -import { ErrorCodes, GenericErrorTypes, DEFAULT_ERROR_MESSAGES, MAX_RETRY_ATTEMPTS } from './constants'; +import { DEFAULT_ERROR_MESSAGES, ErrorCodes, GenericErrorTypes, MAX_RETRY_ATTEMPTS } from './constants'; import { BundleFiles, SupportedFiles } from './interfaces/files.interface'; import { AnalysisResult, ReportResult } from './interfaces/analysis-result.interface'; import { FailedResponse, makeRequest, Payload } from './needle'; import { - AnalysisOptions, AnalysisContext, + AnalysisOptions, ReportOptions, ScmReportOptions, } from './interfaces/analysis-options.interface'; -import { getURL } from './utils/httpUtils'; +import { generateJsonApiError, getURL, isJsonApiErrors } from './utils/httpUtils'; type ResultSuccess = { type: 'success'; value: T }; -type ResultError = { + +export type ResultError = { type: 'error'; error: { - statusCode: E; + statusCode: E | number; statusText: string; apiName: string; + detail?: string | undefined; }; }; @@ -41,24 +43,26 @@ export interface ConnectionOptions { // The trick to typecast union type alias // eslint-disable-next-line @typescript-eslint/no-explicit-any function isSubsetErrorCode(code: any, messages: { [c: number]: string }): code is T { - if (code in messages) { - return true; - } - return false; + return code in messages; } function generateError( errorCode: number, messages: { [c: number]: string }, apiName: string, - error?: string, + errorMessage?: string, + errors?: unknown, ): ResultError { + if (isJsonApiErrors(errors)) { + return generateJsonApiError(errors, errorCode, apiName); + } + if (!isSubsetErrorCode(errorCode, messages)) { - throw { errorCode, messages, apiName }; + throw { statusCode: errorCode, statusText: errorMessage || 'unknown error occurred', apiName }; } const statusCode = errorCode; - const statusText = error ?? messages[errorCode]; + const statusText = errorMessage ?? messages[errorCode]; return { type: 'error', @@ -82,6 +86,7 @@ const GENERIC_ERROR_MESSAGES: { [P in GenericErrorTypes]: string } = { [ErrorCodes.timeout]: DEFAULT_ERROR_MESSAGES[ErrorCodes.timeout], [ErrorCodes.dnsNotFound]: DEFAULT_ERROR_MESSAGES[ErrorCodes.dnsNotFound], [ErrorCodes.connectionRefused]: DEFAULT_ERROR_MESSAGES[ErrorCodes.connectionRefused], + [ErrorCodes.unprocessableEntity]: DEFAULT_ERROR_MESSAGES[ErrorCodes.unprocessableEntity], }; interface StartSessionOptions { @@ -92,8 +97,7 @@ interface StartSessionOptions { export async function compressAndEncode(payload: unknown): Promise { // encode payload and compress; const deflate = promisify(gzip); - const compressedPayload = await deflate(Buffer.from(JSON.stringify(payload)).toString('base64')); - return compressedPayload; + return await deflate(Buffer.from(JSON.stringify(payload)).toString('base64')); } export function startSession(options: StartSessionOptions): StartSessionResponseDto { @@ -271,7 +275,13 @@ export async function createBundle( if (res.success) { return { type: 'success', value: res.body }; } - return generateError(res.errorCode, CREATE_BUNDLE_ERROR_MESSAGES, 'createBundle'); + return generateError( + res.errorCode, + CREATE_BUNDLE_ERROR_MESSAGES, + 'createBundle', + undefined, + res.errors, + ); } export type CheckBundleErrorCodes = @@ -359,7 +369,13 @@ export async function extendBundle( isJson: false, }); if (res.success) return { type: 'success', value: res.body }; - return generateError(res.errorCode, EXTEND_BUNDLE_ERROR_MESSAGES, 'extendBundle'); + return generateError( + res.errorCode, + EXTEND_BUNDLE_ERROR_MESSAGES, + 'extendBundle', + undefined, + res.errors, + ); } // eslint-disable-next-line no-shadow @@ -431,8 +447,16 @@ export async function getAnalysis( }; const res = await makeRequest(config); - if (res.success) return { type: 'success', value: res.body }; - return generateError(res.errorCode, GET_ANALYSIS_ERROR_MESSAGES, 'getAnalysis'); + if (res.success) { + return { type: 'success', value: res.body }; + } + return generateError( + res.errorCode, + GET_ANALYSIS_ERROR_MESSAGES, + 'getAnalysis', + undefined, + res.errors, + ); } export type ReportErrorCodes = diff --git a/src/interfaces/json-api.ts b/src/interfaces/json-api.ts new file mode 100644 index 00000000..4bec71a1 --- /dev/null +++ b/src/interfaces/json-api.ts @@ -0,0 +1,9 @@ +export type JsonApiError = { + links?: { + about?: string; + }; + status: string; + code: string; + title: string; + detail: string; +}; diff --git a/src/needle.ts b/src/needle.ts index a306152a..dec2682f 100644 --- a/src/needle.ts +++ b/src/needle.ts @@ -45,10 +45,12 @@ interface SuccessResponse { success: true; body: T; } + export type FailedResponse = { success: false; errorCode: number; error: Error | undefined; + errors?: unknown; }; export async function makeRequest( @@ -109,6 +111,9 @@ export async function makeRequest( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const errorMessage = response?.body?.error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const errors = response?.body?.errors as unknown; + if (errorMessage) { error = error ?? new Error(errorMessage); } @@ -130,7 +135,7 @@ export async function makeRequest( await sleep(REQUEST_RETRY_DELAY); } else { attempts = 0; - return { success: false, errorCode, error }; + return { success: false, errorCode, error, errors }; } } while (attempts > 0); diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts index 7bd627df..f06f5a3b 100644 --- a/src/utils/httpUtils.ts +++ b/src/utils/httpUtils.ts @@ -1,4 +1,6 @@ import { ORG_ID_REGEXP } from '../constants'; +import { JsonApiError } from '../interfaces/json-api'; +import { ResultError } from '../http'; export function getURL(baseURL: string, path: string, orgId?: string): string { if (routeToGateway(baseURL)) { @@ -17,3 +19,39 @@ function routeToGateway(baseURL: string): boolean { function isValidOrg(orgId?: string): boolean { return orgId !== undefined && ORG_ID_REGEXP.test(orgId); } + +export function isJsonApiErrors(input: unknown): input is JsonApiError[] { + if (!Array.isArray(input)) { + return false; + } + + for (const element of input) { + if ( + typeof element !== 'object' || + !('status' in element) || + !('code' in element) || + !('title' in element) || + !('detail' in element) + ) { + return false; + } + } + + return true; +} + +export function generateJsonApiError(errors: JsonApiError[], statusCode: number, apiName: string): ResultError { + const error = errors[0]; + const errorLink = error.links?.about; + const detail = `${error.title}: ${error.detail}${errorLink ? ` (more info: ${errorLink})` : ``}`; + const statusText = error.title; + return { + type: 'error', + error: { + apiName, + statusCode, + statusText, + detail, + }, + }; +} diff --git a/tests/http.spec.ts b/tests/http.spec.ts index 618542d5..e6a4f3e7 100644 --- a/tests/http.spec.ts +++ b/tests/http.spec.ts @@ -1,5 +1,17 @@ -import needle from 'needle'; -import { getVerifyCallbackUrl } from '../src/http'; +import { createBundle, extendBundle, getAnalysis, getVerifyCallbackUrl, ResultError } from '../src/http'; +import { baseURL, source } from './constants/base'; +import * as needle from '../src/needle'; +import { GenericErrorTypes } from '../src/constants'; + +const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links: { + about: 'https://snyk.io', + }, +}; describe('HTTP', () => { const authHost = 'https://dev.snyk.io'; @@ -41,4 +53,111 @@ describe('HTTP', () => { expect(url).toBe(`${authHost}/api/verify/callback`); }); + + describe('getAnalysis', () => { + const options = { + baseURL, + sessionToken: 'token', + bundleHash: '123', + severity: 1, + source, + }; + + it('should return error on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 400, error: new Error('uh oh') }); + + const result = (await getAnalysis(options)) as ResultError; + + expect(result.error.apiName).toEqual('getAnalysis'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(400); + }); + + it('should return error with detail for json api type errors on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] }); + + const result = (await getAnalysis(options)) as ResultError; + + expect(result.error.apiName).toEqual('getAnalysis'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(422); + expect(result.error.detail).toBeTruthy(); + }); + }); + + describe('createBundle', () => { + const options = { + baseURL, + sessionToken: 'token', + bundleHash: '123', + severity: 1, + source, + files: {}, + }; + + it('should return error on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 400, error: new Error('uh oh') }); + + const result = (await createBundle(options)) as ResultError; + + expect(result.error.apiName).toEqual('createBundle'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(400); + }); + + it('should return error with detail for json api type errors on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] }); + + const result = (await createBundle(options)) as ResultError; + + expect(result.error.apiName).toEqual('createBundle'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(422); + expect(result.error.detail).toBeTruthy(); + }); + }); + + describe('extendBundle', () => { + const options = { + baseURL, + sessionToken: 'token', + bundleHash: '123', + severity: 1, + source, + files: {}, + }; + + it('should return error on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 400, error: new Error('uh oh') }); + + const result = (await extendBundle(options)) as ResultError; + + expect(result.error.apiName).toEqual('extendBundle'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(400); + }); + + it('should return error with detail for json api type errors on failed response', async () => { + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] }); + + const result = (await extendBundle(options)) as ResultError; + + expect(result.error.apiName).toEqual('extendBundle'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(422); + expect(result.error.detail).toBeTruthy(); + }); + }); }); diff --git a/tests/httpUtils.spec.ts b/tests/httpUtils.spec.ts index ba37ce9a..5464bbf2 100644 --- a/tests/httpUtils.spec.ts +++ b/tests/httpUtils.spec.ts @@ -1,4 +1,4 @@ -import { getURL } from '../src/utils/httpUtils'; +import { generateJsonApiError, getURL, isJsonApiErrors } from '../src/utils/httpUtils'; describe('getURL', () => { it('should return base + path if not fedramp', () => { @@ -35,3 +35,81 @@ describe('getURL', () => { expect(() => getURL(base, path, orgId)).toThrowError('A valid Org id is required for this operation'); }); }); + +describe('isJsonApiErrors', () => { + it('should return true if input is an array of json api formatted errors', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'bad error: detail', + links: { + about: 'https://snyk.io', + }, + }; + expect(isJsonApiErrors([jsonApiError])).toBeTruthy(); + }); + + it('should return false if input is not an array', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'bad error: detail', + links: { + about: 'https://snyk.io', + }, + }; + expect(isJsonApiErrors(jsonApiError)).toBeFalsy(); + }); + + it('should return false if input is an array of non json api formatted errors', () => { + const jsonApiError = { + status: '422', + }; + expect(isJsonApiErrors(jsonApiError)).toBeFalsy(); + }); +}); + +describe('generateJsonApiError', () => { + it('should return detail with link', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links: { + about: 'https://snyk.io', + }, + }; + + expect(generateJsonApiError([jsonApiError], 422, 'test')).toEqual({ + type: 'error', + error: { + apiName: 'test', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error: detail (more info: https://snyk.io)', + }, + }); + }); + + it('should return detail with no link if not present', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + }; + + expect(generateJsonApiError([jsonApiError], 422, 'test')).toEqual({ + type: 'error', + error: { + apiName: 'test', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error: detail', + }, + }); + }); +});