diff --git a/src/http.ts b/src/http.ts index 49de9d95..9fc004b7 100644 --- a/src/http.ts +++ b/src/http.ts @@ -15,7 +15,7 @@ import { ScmReportOptions, } from './interfaces/analysis-options.interface'; import { generateErrorWithDetail, getURL } from './utils/httpUtils'; -import { JsonApiError } from './interfaces/json-api'; +import { JsonApiErrorObject } from './interfaces/json-api'; type ResultSuccess = { type: 'success'; value: T }; @@ -52,10 +52,10 @@ function generateError( messages: { [c: number]: string }, apiName: string, errorMessage?: string, - jsonApiError?: JsonApiError, + errors?: JsonApiErrorObject[], ): ResultError { - if (jsonApiError) { - return generateErrorWithDetail(jsonApiError, errorCode, apiName); + if (errors) { + return generateErrorWithDetail(errors[0], errorCode, apiName); } if (!isSubsetErrorCode(errorCode, messages)) { @@ -222,7 +222,7 @@ export async function getFilters({ if (res.success) { return { type: 'success', value: res.body }; } - return generateError(res.errorCode, GENERIC_ERROR_MESSAGES, apiName, undefined, res.jsonApiError); + return generateError(res.errorCode, GENERIC_ERROR_MESSAGES, apiName, undefined, res.errors); } function commonHttpHeaders(options: ConnectionOptions) { @@ -294,7 +294,7 @@ export async function createBundle( CREATE_BUNDLE_ERROR_MESSAGES, 'createBundle', undefined, - res.jsonApiError, + res.errors, ); } @@ -338,7 +338,7 @@ export async function checkBundle(options: CheckBundleOptions): Promise(config); if (res.success) return { type: 'success', value: res.body.reportId }; - return generateError( - res.errorCode, - REPORT_ERROR_MESSAGES, - 'initReport', - undefined, - res.jsonApiError, - ); + return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'initReport', undefined, res.errors); } /** @@ -588,7 +582,7 @@ export async function getReport(options: GetReportOptions): Promise(config); if (res.success) return { type: 'success', value: res.body.testId }; - return generateError( - res.errorCode, - REPORT_ERROR_MESSAGES, - 'initReport', - undefined, - res.jsonApiError, - ); + return generateError(res.errorCode, REPORT_ERROR_MESSAGES, 'initReport', undefined, res.errors); } /** @@ -658,6 +646,6 @@ export async function getScmReport( REPORT_ERROR_MESSAGES, 'getReport', res.error?.message, - res.jsonApiError, + res.errors, ); } diff --git a/src/interfaces/json-api.ts b/src/interfaces/json-api.ts index 4bec71a1..8298bb82 100644 --- a/src/interfaces/json-api.ts +++ b/src/interfaces/json-api.ts @@ -1,4 +1,13 @@ -export type JsonApiError = { +enum Classification { + UNEXPECTED = 'UNEXPECTED', + ACTIONABLE = 'ACTIONABLE', + UNSUPPORTED = 'UNSUPPORTED', +} + +type JsonApiErrorSource = { pointer: string } | { parameter: string } | { header: string }; + +export type JsonApiErrorObject = { + id?: string; links?: { about?: string; }; @@ -6,4 +15,17 @@ export type JsonApiError = { code: string; title: string; detail: string; + source?: JsonApiErrorSource; + meta: { + [x: string]: any; + + // Allow consumers to probe if this specific type of JsonApi + // response originates from an error catalog error. + isErrorCatalogError: boolean; + + classification?: Classification; + + // Logs related to the error that was thrown + logs?: string[]; + }; }; diff --git a/src/needle.ts b/src/needle.ts index a83d39c8..3b9a22c0 100644 --- a/src/needle.ts +++ b/src/needle.ts @@ -4,11 +4,10 @@ import needle from 'needle'; import * as querystring from 'querystring'; import https from 'https'; import { URL } from 'url'; -import { emitter } from './emitter'; +import { JsonApiErrorObject } from './interfaces/json-api'; +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)); @@ -52,7 +51,7 @@ export type FailedResponse = { success: false; errorCode: number; error: Error | undefined; - jsonApiError?: JsonApiError | undefined; + errors?: JsonApiErrorObject[] | undefined; }; export async function makeRequest( @@ -98,7 +97,6 @@ export async function makeRequest( do { let errorCode: number | undefined; let error: Error | undefined; - let jsonApiError: JsonApiError | undefined; let response: needle.NeedleResponse | undefined; try { @@ -116,11 +114,7 @@ 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]; - } + const errors = response?.body?.errors as JsonApiErrorObject[] | undefined; if (errorMessage) { error = error ?? new Error(errorMessage); @@ -143,7 +137,7 @@ export async function makeRequest( await sleep(REQUEST_RETRY_DELAY); } else { attempts = 0; - return { success: false, errorCode, error, jsonApiError }; + return { success: false, errorCode, error, errors }; } } while (attempts > 0); diff --git a/src/utils/httpUtils.ts b/src/utils/httpUtils.ts index 049adf77..7248f2af 100644 --- a/src/utils/httpUtils.ts +++ b/src/utils/httpUtils.ts @@ -1,5 +1,5 @@ import { ORG_ID_REGEXP } from '../constants'; -import { JsonApiError } from '../interfaces/json-api'; +import { JsonApiErrorObject } from '../interfaces/json-api'; import { ResultError } from '../http'; export function getURL(baseURL: string, path: string, orgId?: string): string { @@ -20,27 +20,11 @@ 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 { +export function generateErrorWithDetail( + error: JsonApiErrorObject, + statusCode: number, + apiName: string, +): ResultError { const errorLink = error.links?.about; const detail = `${error.title}${error.detail ? `: ${error.detail}` : ''}${ errorLink ? ` (more info: ${errorLink})` : `` diff --git a/tests/api.spec.ts b/tests/api.spec.ts index 8d51e5ce..d124ee84 100644 --- a/tests/api.spec.ts +++ b/tests/api.spec.ts @@ -327,7 +327,7 @@ describe('Requests to public API', () => { if (response.value.status === AnalysisStatus.complete && response.value.type === 'sarif') { expect(response.value.sarif.runs[0].results?.length).toBeGreaterThan(0); - expect(response.value.coverage).toIncludeSameMembers([ + expect(response.value.coverage).toIncludeAllMembers([ { files: 2, isSupported: true, diff --git a/tests/http.spec.ts b/tests/http.spec.ts index 9bd56cb6..64d37217 100644 --- a/tests/http.spec.ts +++ b/tests/http.spec.ts @@ -22,6 +22,9 @@ const jsonApiError = { links: { about: 'https://snyk.io', }, + meta: { + isErrorCatalogError: true, + }, }; const error = new Error('uh oh'); @@ -79,7 +82,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await getFilters({ baseURL, source, attempts: 1, extraHeaders: {} }); @@ -108,7 +113,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await getAnalysis(options); @@ -138,7 +145,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await createBundle(options); @@ -168,7 +177,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await checkBundle(options); @@ -198,7 +209,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await extendBundle(options); @@ -229,7 +242,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await initReport(options); @@ -257,7 +272,9 @@ describe('HTTP', () => { }); 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 }); + jest + .spyOn(needle, 'makeRequest') + .mockResolvedValue({ success: false, errorCode: 422, error, errors: [jsonApiError] }); const spy = jest.spyOn(httpUtils, 'generateErrorWithDetail'); await getReport(options); diff --git a/tests/httpUtils.spec.ts b/tests/httpUtils.spec.ts index 5893dfe5..3418ef6e 100644 --- a/tests/httpUtils.spec.ts +++ b/tests/httpUtils.spec.ts @@ -1,4 +1,4 @@ -import { generateErrorWithDetail, getURL, isJsonApiErrors } from '../src/utils/httpUtils'; +import { generateErrorWithDetail, getURL } from '../src/utils/httpUtils'; describe('getURL', () => { it('should return base + path if not fedramp', () => { @@ -36,51 +36,19 @@ describe('getURL', () => { }); }); -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', + code: 'SNYK-CODE-0001', title: 'bad error', detail: 'detail', links: { about: 'https://snyk.io', }, + meta: { + isErrorCatalogError: true, + }, }; expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ @@ -97,9 +65,12 @@ describe('generateErrorWithDetail', () => { it('should return detail with no link if not present', () => { const jsonApiError = { status: '422', - code: 'SNYK_0001', + code: 'SNYK-CODE-0001', title: 'bad error', detail: 'detail', + meta: { + isErrorCatalogError: true, + }, }; expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ @@ -116,12 +87,15 @@ describe('generateErrorWithDetail', () => { it('should return detail with title and link when detail is empty string', () => { const jsonApiError = { status: '422', - code: 'SNYK_0001', + code: 'SNYK-CODE-0001', title: 'bad error', detail: '', links: { about: 'https://snyk.io', }, + meta: { + isErrorCatalogError: true, + }, }; expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({ @@ -138,9 +112,12 @@ describe('generateErrorWithDetail', () => { it('should return detail with title when detail is empty string and no link', () => { const jsonApiError = { status: '422', - code: 'SNYK_0001', + code: 'SNYK-CODE-0001', title: 'bad error', detail: '', + meta: { + isErrorCatalogError: true, + }, }; expect(generateErrorWithDetail(jsonApiError, 422, 'test')).toEqual({