Skip to content

Commit

Permalink
feat(api): expose HTTP response from API errors (#12835)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Hui Zhao <[email protected]>
Co-authored-by: Jim Blanchard <[email protected]>
  • Loading branch information
3 people authored Jan 30, 2024
1 parent 9f41cdb commit 38c1d56
Show file tree
Hide file tree
Showing 14 changed files with 355 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
/**
* @internal
*/
export class APIError extends AmplifyError {
export class GraphQLApiError extends AmplifyError {
constructor(params: AmplifyErrorParams) {
super(params);

// Hack for making the custom error class work when transpiled to es5
// TODO: Delete the following 2 lines after we change the build target to >= es2015
this.constructor = APIError;
Object.setPrototypeOf(this, APIError.prototype);
this.constructor = GraphQLApiError;
Object.setPrototypeOf(this, GraphQLApiError.prototype);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { APIError } from './APIError';
import { GraphQLApiError } from './GraphQLApiError';
import { APIValidationErrorCode, validationErrorMap } from './validation';

/**
Expand All @@ -14,6 +14,6 @@ export function assertValidationError(
const { message, recoverySuggestion } = validationErrorMap[name];

if (!assertion) {
throw new APIError({ name, message, recoverySuggestion });
throw new GraphQLApiError({ name, message, recoverySuggestion });
}
}
2 changes: 1 addition & 1 deletion packages/api-graphql/src/utils/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export { APIError } from './APIError';
export { GraphQLApiError } from './GraphQLApiError';
export { assertValidationError } from './assertValidationError';
export { APIValidationErrorCode, validationErrorMap } from './validation';
77 changes: 70 additions & 7 deletions packages/api-rest/__tests__/apis/common/internalPost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { AmplifyClassV6 } from '@aws-amplify/core';
import { ApiError } from '@aws-amplify/core/internals/utils';
import {
authenticatedHandler,
unauthenticatedHandler,
Expand Down Expand Up @@ -265,29 +266,91 @@ describe('internal post', () => {
}
});

it('should throw RestApiError when response is not ok', async () => {
expect.assertions(2);
it('should throw RestApiError when error response conforms to AWS service errors', async () => {
expect.assertions(4);
const errorResponseObj = { message: 'fooMessage', name: 'badRequest' };
const errorResponse = {
statusCode: 400,
headers: {},
body: {
blob: jest.fn(),
json: jest.fn(),
text: jest.fn(),
text: jest.fn().mockResolvedValue(JSON.stringify(errorResponseObj)),
},
};
mockParseJsonError.mockResolvedValueOnce(
new RestApiError({ message: 'fooMessage', name: 'badRequest' })
);
mockParseJsonError.mockImplementationOnce(async response => {
const errorResponsePayload = await response.body?.json();
const error = new Error(errorResponsePayload.message);
return Object.assign(error, {
name: errorResponsePayload.name,
});
});
mockUnauthenticatedHandler.mockResolvedValueOnce(errorResponse);
try {
await post(mockAmplifyInstance, {
url: apiGatewayUrl,
});
fail('should throw RestApiError');
} catch (error) {
expect(mockParseJsonError).toHaveBeenCalledWith({
...errorResponse,
body: {
json: expect.any(Function),
blob: expect.any(Function),
text: expect.any(Function),
},
});
expect(error).toEqual(expect.any(RestApiError));
expect(error).toEqual(expect.any(ApiError));
expect((error as ApiError).response).toEqual({
statusCode: 400,
headers: {},
body: JSON.stringify(errorResponseObj),
});
}
});

it('should throw when error response has custom payload', async () => {
expect.assertions(4);
const errorResponseStr = 'custom error message';
const errorResponse = {
statusCode: 400,
headers: {},
body: {
blob: jest.fn(),
json: jest.fn(),
text: jest.fn().mockResolvedValue(errorResponseStr),
},
};
mockParseJsonError.mockImplementationOnce(async response => {
const errorResponsePayload = await response.body?.json();
const error = new Error(errorResponsePayload.message);
return Object.assign(error, {
name: errorResponsePayload.name,
});
});
mockUnauthenticatedHandler.mockResolvedValueOnce(errorResponse);
try {
await post(mockAmplifyInstance, {
url: apiGatewayUrl,
});
fail('should throw RestApiError');
} catch (error) {
expect(mockParseJsonError).toHaveBeenCalledWith(errorResponse);
expect(mockParseJsonError).toHaveBeenCalledWith({
...errorResponse,
body: {
json: expect.any(Function),
blob: expect.any(Function),
text: expect.any(Function),
},
});
expect(error).toEqual(expect.any(RestApiError));
expect(error).toEqual(expect.any(ApiError));
expect((error as ApiError).response).toEqual({
statusCode: 400,
headers: {},
body: errorResponseStr,
});
}
});
});
78 changes: 71 additions & 7 deletions packages/api-rest/__tests__/apis/common/publicApis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
unauthenticatedHandler,
parseJsonError,
} from '@aws-amplify/core/internals/aws-client-utils';
import { ApiError } from '@aws-amplify/core/internals/utils';

import {
get,
Expand Down Expand Up @@ -288,20 +289,70 @@ describe('public APIs', () => {
);
});

it('should throw when response is not ok', async () => {
expect.assertions(2);
it('should throw when error response conforms to AWS service errors', async () => {
expect.assertions(4);
const errorResponseObj = { message: 'fooMessage', name: 'badRequest' };
const errorResponse = {
statusCode: 400,
headers: {},
body: {
blob: jest.fn(),
json: jest.fn(),
text: jest.fn(),
text: jest.fn().mockResolvedValue(JSON.stringify(errorResponseObj)),
},
};
mockParseJsonError.mockResolvedValueOnce(
new RestApiError({ message: 'fooMessage', name: 'badRequest' })
);
mockParseJsonError.mockImplementationOnce(async response => {
const errorResponsePayload = await response.body?.json();
const error = new Error(errorResponsePayload.message);
return Object.assign(error, {
name: errorResponsePayload.name,
});
});
mockAuthenticatedHandler.mockResolvedValueOnce(errorResponse);
try {
await fn(mockAmplifyInstance, {
apiName: 'restApi1',
path: '/items',
}).response;
fail('should throw RestApiError');
} catch (error) {
expect(mockParseJsonError).toHaveBeenCalledWith({
...errorResponse,
body: {
json: expect.any(Function),
blob: expect.any(Function),
text: expect.any(Function),
},
});
expect(error).toEqual(expect.any(RestApiError));
expect(error).toEqual(expect.any(ApiError));
expect((error as ApiError).response).toEqual({
statusCode: 400,
headers: {},
body: JSON.stringify(errorResponseObj),
});
}
});

it('should throw when error response has custom payload', async () => {
expect.assertions(4);
const errorResponseStr = 'custom error message';
const errorResponse = {
statusCode: 400,
headers: {},
body: {
blob: jest.fn(),
json: jest.fn(),
text: jest.fn().mockResolvedValue(errorResponseStr),
},
};
mockParseJsonError.mockImplementationOnce(async response => {
const errorResponsePayload = await response.body?.json();
const error = new Error(errorResponsePayload.message);
return Object.assign(error, {
name: errorResponsePayload.name,
});
});
mockAuthenticatedHandler.mockResolvedValueOnce(errorResponse);
try {
await fn(mockAmplifyInstance, {
Expand All @@ -310,8 +361,21 @@ describe('public APIs', () => {
}).response;
fail('should throw RestApiError');
} catch (error) {
expect(mockParseJsonError).toHaveBeenCalledWith(errorResponse);
expect(mockParseJsonError).toHaveBeenCalledWith({
...errorResponse,
body: {
json: expect.any(Function),
blob: expect.any(Function),
text: expect.any(Function),
},
});
expect(error).toEqual(expect.any(RestApiError));
expect(error).toEqual(expect.any(ApiError));
expect((error as ApiError).response).toEqual({
statusCode: 400,
headers: {},
body: errorResponseStr,
});
}
});

Expand Down
49 changes: 49 additions & 0 deletions packages/api-rest/__tests__/utils/serviceError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { parseJsonError as parseAwsJsonError } from '@aws-amplify/core/internals/aws-client-utils';

import { parseRestApiServiceError } from '../../src/utils';
import { RestApiError } from '../../src/errors';

jest.mock('@aws-amplify/core/internals/aws-client-utils');

const mockParseJsonError = parseAwsJsonError as jest.Mock;
const mockResponse = {
statusCode: 400,
headers: {},
body: {
json: jest.fn(),
blob: jest.fn(),
text: jest.fn(),
},
};

describe('parseRestApiServiceError', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should return undefined if response is undefined', async () => {
expect.assertions(1);
expect(await parseRestApiServiceError(undefined)).toBeUndefined();
});

it('should return undefined if AWS JSON error parser returns undefined', async () => {
expect.assertions(1);
mockParseJsonError.mockReturnValue(undefined);
expect(await parseRestApiServiceError(mockResponse)).toBeUndefined();
});

it('should return a RestApiError with the parsed AWS error', async () => {
expect.assertions(2);
const parsedAwsError = {
name: 'UnknownError',
message: 'Unknown error',
$metadata: {},
};
mockParseJsonError.mockResolvedValue(parsedAwsError);
const parsedRestApiError = await parseRestApiServiceError(mockResponse);
expect(parsedRestApiError).toBeInstanceOf(RestApiError);
expect(parsedRestApiError).toMatchObject(parsedAwsError);
});
});
9 changes: 3 additions & 6 deletions packages/api-rest/src/errors/RestApiError.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
AmplifyError,
AmplifyErrorParams,
} from '@aws-amplify/core/internals/utils';
import { ApiError, ApiErrorParams } from '@aws-amplify/core/internals/utils';

export class RestApiError extends AmplifyError {
constructor(params: AmplifyErrorParams) {
export class RestApiError extends ApiError {
constructor(params: ApiErrorParams) {
super(params);

// TODO: Delete the following 2 lines after we change the build target to >= es2015
Expand Down
Loading

0 comments on commit 38c1d56

Please sign in to comment.