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..72ee69dc 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 { 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,50 @@ 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 code in messages; +} + +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 false; + + return true; } function generateError( errorCode: number, messages: { [c: number]: string }, apiName: string, - error?: string, + errorMessage?: string, + errors?: unknown, ): ResultError { 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]; + let statusText = errorMessage ?? messages[errorCode]; + let detail; + + if (isJsonApiErrors(errors)) { + const error = errors[0]; + const errorLink = error.links?.about; + detail = `${error.title}${errorLink ? `, more info: ${errorLink}` : ``}`; + statusText = error.title; + } return { type: 'error', @@ -66,6 +95,7 @@ function generateError( apiName, statusCode, statusText, + detail, }, }; } @@ -82,6 +112,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 +123,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 +301,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 = @@ -431,8 +467,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/tests/http.spec.ts b/tests/http.spec.ts index 618542d5..9f8093fb 100644 --- a/tests/http.spec.ts +++ b/tests/http.spec.ts @@ -1,5 +1,14 @@ -import needle from 'needle'; -import { getVerifyCallbackUrl } from '../src/http'; +import { + createBundle, + CreateBundleErrorCodes, + getAnalysis, + GetAnalysisErrorCodes, + getVerifyCallbackUrl, + ResultError, +} from '../src/http'; +import { baseURL, source } from './constants/base'; +import * as needle from '../src/needle'; + describe('HTTP', () => { const authHost = 'https://dev.snyk.io'; @@ -41,4 +50,155 @@ 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).toEqual({ + apiName: 'getAnalysis', + statusCode: 400, + statusText: 'Bad request', + }); + }); + + it('should return error with detail for json api type errors on failed response', async () => { + const jsonApiError = { + status: '500', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + } + + 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.type).toEqual('error') + expect(result.error).toEqual({ + apiName: 'getAnalysis', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error' + }); + }); + + it('should return error with detail for json api type errors with link on failed response', async () => { + const jsonApiError = { + status: '500', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links : { + about: 'https://snyk.io' + } + } + + 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.type).toEqual('error') + expect(result.error).toEqual({ + apiName: 'getAnalysis', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error, more info: https://snyk.io' + }); + }); + }) + + 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).toEqual({ + apiName: 'createBundle', + statusCode: 400, + statusText: 'Request payload doesn\'t match the specifications', + }); + }); + + it('should return error with detail for json api type errors on failed response', async () => { + const jsonApiError = { + status: '500', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + } + + 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.type).toEqual('error') + expect(result.error).toEqual({ + apiName: 'createBundle', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error' + }); + }); + + it('should return error with detail for json api type errors with link on failed response', async () => { + const jsonApiError = { + status: '500', + code: 'SNYK_0001', + title: 'bad error', + detail: 'detail', + links : { + about: 'https://snyk.io' + } + } + + 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.type).toEqual('error') + expect(result.error).toEqual({ + apiName: 'createBundle', + statusCode: 422, + statusText: 'bad error', + detail: 'bad error, more info: https://snyk.io' + }); + }); + }) });