diff --git a/src/http.ts b/src/http.ts index 88578139..69cf5b5c 100644 --- a/src/http.ts +++ b/src/http.ts @@ -3,26 +3,29 @@ 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 { generateErrorWithDetail, getURL } from './utils/httpUtils'; +import { JsonApiError } from './interfaces/json-api'; type ResultSuccess = { type: 'success'; value: T }; -type ResultError = { + +export type ResultError = { type: 'error'; error: { statusCode: E; statusText: string; apiName: string; + detail?: string | undefined; }; }; @@ -41,24 +44,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, + jsonApiError?: JsonApiError, ): ResultError { + if (jsonApiError) { + return generateErrorWithDetail(jsonApiError, 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', @@ -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 { @@ -204,7 +208,7 @@ export async function getFilters( if (res.success) { return { type: 'success', value: res.body }; } - return generateError(res.errorCode, GENERIC_ERROR_MESSAGES, apiName); + return generateError(res.errorCode, GENERIC_ERROR_MESSAGES, apiName, undefined, res.jsonApiError); } function commonHttpHeaders(options: ConnectionOptions) { @@ -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.jsonApiError, + ); } export type CheckBundleErrorCodes = @@ -309,7 +319,13 @@ export async function checkBundle(options: CheckBundleOptions): Promise(res.errorCode, CHECK_BUNDLE_ERROR_MESSAGES, 'checkBundle'); + return generateError( + res.errorCode, + CHECK_BUNDLE_ERROR_MESSAGES, + 'checkBundle', + undefined, + res.jsonApiError, + ); } export type ExtendBundleErrorCodes = @@ -359,7 +375,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.jsonApiError, + ); } // eslint-disable-next-line no-shadow @@ -431,8 +453,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.jsonApiError, + ); } export type ReportErrorCodes = @@ -508,7 +538,13 @@ export async function initReport(options: UploadReportOptions): Promise(config); if (res.success) return { type: 'success', value: res.body.reportId }; - return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'initReport'); + return generateError( + res.errorCode, + REPORT_ERROR_MESSAGES, + 'initReport', + undefined, + res.jsonApiError, + ); } /** @@ -533,7 +569,13 @@ export async function getReport(options: GetReportOptions): Promise(config); if (res.success) return { type: 'success', value: res.body }; - return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'getReport', res.error?.message); + return generateError( + res.errorCode, + REPORT_ERROR_MESSAGES, + 'getReport', + res.error?.message, + res.jsonApiError, + ); } /** @@ -564,7 +606,13 @@ export async function initScmReport(options: ScmUploadReportOptions): Promise(config); if (res.success) return { type: 'success', value: res.body.testId }; - return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'initReport'); + return generateError( + res.errorCode, + REPORT_ERROR_MESSAGES, + 'initReport', + undefined, + res.jsonApiError, + ); } /** @@ -591,5 +639,11 @@ export async function getScmReport( const res = await makeRequest(config); if (res.success) return { type: 'success', value: res.body }; - return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'getReport', res.error?.message); + return generateError( + res.errorCode, + REPORT_ERROR_MESSAGES, + 'getReport', + res.error?.message, + res.jsonApiError, + ); } 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..a83d39c8 100644 --- a/src/needle.ts +++ b/src/needle.ts @@ -7,6 +7,8 @@ import { URL } from 'url'; import { emitter } from './emitter'; import { ErrorCodes, NETWORK_ERRORS, MAX_RETRY_ATTEMPTS, REQUEST_RETRY_DELAY } from './constants'; +import { JsonApiError } from './interfaces/json-api'; +import { isJsonApiErrors } from './utils/httpUtils'; const sleep = (duration: number) => new Promise(resolve => setTimeout(resolve, duration)); @@ -45,10 +47,12 @@ interface SuccessResponse { success: true; body: T; } + export type FailedResponse = { success: false; errorCode: number; error: Error | undefined; + jsonApiError?: JsonApiError | undefined; }; export async function makeRequest( @@ -94,7 +98,9 @@ export async function makeRequest( do { let errorCode: number | undefined; let error: Error | undefined; + let jsonApiError: JsonApiError | undefined; let response: needle.NeedleResponse | undefined; + try { response = await needle(method, url, data, options); emitter.apiRequestLog(`<= Response: ${response.statusCode} ${JSON.stringify(response.body)}`); @@ -109,6 +115,13 @@ 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-assignment,@typescript-eslint/no-unsafe-member-access + const errors = response?.body?.errors; + + if (isJsonApiErrors(errors)) { + jsonApiError = errors[0]; + } + if (errorMessage) { error = error ?? new Error(errorMessage); } @@ -130,7 +143,7 @@ export async function makeRequest( await sleep(REQUEST_RETRY_DELAY); } else { attempts = 0; - return { success: false, errorCode, error }; + return { success: false, errorCode, error, jsonApiError }; } } while (attempts > 0); diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts index 7bd627df..049adf77 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,40 @@ 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) || input.length < 1) { + 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 generateErrorWithDetail(error: JsonApiError, statusCode: number, apiName: string): ResultError { + const errorLink = error.links?.about; + const detail = `${error.title}${error.detail ? `: ${error.detail}` : ''}${ + errorLink ? ` (more info: ${errorLink})` : `` + }`; + const statusText = error.title; + return { + type: 'error', + error: { + apiName, + statusCode: statusCode as unknown as E, + statusText, + detail, + }, + }; +} diff --git a/tests/http.spec.ts b/tests/http.spec.ts index 618542d5..c8fc1e60 100644 --- a/tests/http.spec.ts +++ b/tests/http.spec.ts @@ -1,5 +1,29 @@ -import needle from 'needle'; -import { getVerifyCallbackUrl } from '../src/http'; +import { + checkBundle, + createBundle, + extendBundle, + getAnalysis, + getFilters, + getReport, + getVerifyCallbackUrl, + initReport, + ResultError, +} from '../src/http'; +import { baseURL, source } from './constants/base'; +import * as needle from '../src/needle'; +import * as httpUtils from '../src/utils/httpUtils'; + +const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links: { + about: 'https://snyk.io', + }, +}; + +const error = new Error('uh oh'); describe('HTTP', () => { const authHost = 'https://dev.snyk.io'; @@ -41,4 +65,203 @@ describe('HTTP', () => { expect(url).toBe(`${authHost}/api/verify/callback`); }); + + describe('getFilters', () => { + it('should return error on failed response', async () => { + jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false, errorCode: 500, error }); + + const result = (await getFilters(baseURL, source)) as ResultError; + + expect(result.error.apiName).toEqual('filters'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(500); + }); + + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await getFilters(baseURL, source); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'filters'); + }); + }); + + 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 }); + + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await getAnalysis(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'getAnalysis'); + }); + }); + + 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 }); + + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await createBundle(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'createBundle'); + }); + }); + + describe('checkBundle', () => { + 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: 404, error }); + + const result = (await checkBundle(options)) as ResultError; + + expect(result.error.apiName).toEqual('checkBundle'); + expect(result.error.statusText).toBeTruthy(); + expect(result.error.statusCode).toEqual(404); + }); + + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await checkBundle(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'checkBundle'); + }); + }); + + 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 }); + + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await extendBundle(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'extendBundle'); + }); + }); + + describe('initReport', () => { + const options = { + baseURL, + sessionToken: 'token', + bundleHash: '123', + source, + report: { + enabled: true, + }, + }; + + it('should return error on failed response', async () => { + jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false, errorCode: 400, error }); + + const result = (await initReport(options)) as ResultError; + + expect(result.error.apiName).toEqual('initReport'); + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await initReport(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'initReport'); + }); + }); + + describe('getReport', () => { + const options = { + baseURL, + sessionToken: 'token', + source, + pollId: '1', + }; + + it('should return error on failed response', async () => { + jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false, errorCode: 400, error }); + + const result = (await getReport(options)) as ResultError; + + expect(result.error.apiName).toEqual('getReport'); + 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, jsonApiError }); + const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); + + await getReport(options); + + expect(spy).toHaveBeenCalledWith(jsonApiError, 422, 'getReport'); + }); + }); }); diff --git a/tests/httpUtils.spec.ts b/tests/httpUtils.spec.ts index ba37ce9a..5893dfe5 100644 --- a/tests/httpUtils.spec.ts +++ b/tests/httpUtils.spec.ts @@ -1,4 +1,4 @@ -import { getURL } from '../src/utils/httpUtils'; +import { generateErrorWithDetail, getURL, isJsonApiErrors } from '../src/utils/httpUtils'; describe('getURL', () => { it('should return base + path if not fedramp', () => { @@ -35,3 +35,122 @@ 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('generateErrorWithDetail', () => { + it('should return detail with link', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links: { + about: 'https://snyk.io', + }, + }; + + expect(generateErrorWithDetail(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(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ + type: 'error', + error: { + apiName: 'test', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error: detail', + }, + }); + }); + + it('should return detail with title and link when detail is empty string', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: '', + links: { + about: 'https://snyk.io', + }, + }; + + expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ + type: 'error', + error: { + apiName: 'test', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error (more info: https://snyk.io)', + }, + }); + }); + + it('should return detail with title when detail is empty string and no link', () => { + const jsonApiError = { + status: '422', + code: 'SNYK_0001', + title: 'bad error', + detail: '', + }; + + expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ + type: 'error', + error: { + apiName: 'test', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error', + }, + }); + }); +});