Skip to content

Commit

Permalink
fix(api-graphql): update error handling (#13177)
Browse files Browse the repository at this point in the history
chore(api-graphql): improve error handling

  - Use GraphApiError to create errors to be thrown
    * error message field remained the same as before
    * added recoverySuggestion field for each error case
  - Created createGraphQLResultWithError utility for rewrapping error into GraphQLResult format
  • Loading branch information
HuiSF committed Mar 27, 2024
1 parent d18ee98 commit d4a48f1
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 120 deletions.
117 changes: 85 additions & 32 deletions packages/api-graphql/__tests__/GraphQLAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
import { GetThreadQuery } from './fixtures/with-types/API';
import { AWSAppSyncRealTimeProvider } from '../src/Providers/AWSAppSyncRealTimeProvider';
import { Observable, of } from 'rxjs';
import { GraphQLApiError } from '../src/utils/errors';
import { NO_ENDPOINT } from '../src/utils/errors/constants';
import { GraphQLError } from 'graphql';

const serverManagedFields = {
id: 'some-id',
Expand Down Expand Up @@ -61,13 +64,13 @@ const client = {
isCancelError,
} as V6Client;

afterEach(() => {
jest.restoreAllMocks();
});
const mockFetchAuthSession = (Amplify as any).Auth
.fetchAuthSession as jest.Mock;

describe('API test', () => {
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});

describe('graphql test', () => {
Expand Down Expand Up @@ -738,32 +741,9 @@ describe('API test', () => {
});

test('multi-auth default case api-key, OIDC as auth mode, but no federatedSign', async () => {
Amplify.configure({
API: {
GraphQL: {
defaultAuthMode: 'apiKey',
apiKey: 'FAKE-KEY',
endpoint: 'https://localhost/graphql',
region: 'local-host-h4x',
},
},
});

Amplify.configure({
API: {
GraphQL: {
defaultAuthMode: 'apiKey',
apiKey: 'FAKE-KEY',
endpoint: 'https://localhost/graphql',
region: 'local-host-h4x',
},
},
});
});

test('multi-auth default case api-key, OIDC as auth mode, but no federatedSign', async () => {
const prevMockAccessToken = mockAccessToken;
mockAccessToken = null;
mockFetchAuthSession.mockRejectedValueOnce(
new Error('mock failing fetchAuthSession() call here.'),
);

Amplify.configure({
API: {
Expand Down Expand Up @@ -806,9 +786,6 @@ describe('API test', () => {
authMode: 'oidc',
}),
).rejects.toThrow('No current user');

// Cleanup:
mockAccessToken = prevMockAccessToken;
});

test('multi-auth using CUP as auth mode, but no userpool', async () => {
Expand Down Expand Up @@ -1342,5 +1319,81 @@ describe('API test', () => {
},
);
});

test('throws a GraphQLResult with NO_ENDPOINT error when endpoint is not configured', () => {
const expectedGraphQLApiError = new GraphQLApiError(NO_ENDPOINT);

Amplify.configure({
API: {
GraphQL: {
defaultAuthMode: 'apiKey',
apiKey: 'FAKE-KEY',
region: 'local-host-h4x',
} as any,
},
});

const graphqlVariables = { id: 'some-id' };

expect(() =>
client.graphql({
query: typedQueries.getThread,
variables: graphqlVariables,
authMode: 'iam',
}),
).rejects.toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
new GraphQLError(
expectedGraphQLApiError.message,
null,
null,
null,
null,
expectedGraphQLApiError,
),
]),
}),
);
});

test('throws a GraphQLResult with NetworkError when the `post()` API throws for network error', () => {
const postAPIThrownError = new Error('Network error');
jest
.spyOn((raw.GraphQLAPI as any)._api, 'post')
.mockRejectedValueOnce(postAPIThrownError);

Amplify.configure({
API: {
GraphQL: {
defaultAuthMode: 'userPool',
endpoint: 'https://localhost/graphql',
region: 'local-host-h4x',
},
},
});

const graphqlVariables = { id: 'some-id' };

expect(
client.graphql({
query: typedQueries.getThread,
variables: graphqlVariables,
}),
).rejects.toEqual(
expect.objectContaining({
errors: expect.arrayContaining([
new GraphQLError(
postAPIThrownError.message,
null,
null,
null,
null,
postAPIThrownError,
),
]),
}),
);
});
});
});
106 changes: 50 additions & 56 deletions packages/api-graphql/src/internals/InternalGraphQLAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
import {
DocumentNode,
GraphQLError,
OperationDefinitionNode,
OperationTypeNode,
parse,
Expand All @@ -25,14 +24,20 @@ import {
import { CustomHeaders, RequestOptions } from '@aws-amplify/data-schema-types';

import { AWSAppSyncRealTimeProvider } from '../Providers/AWSAppSyncRealTimeProvider';
import {
GraphQLAuthError,
GraphQLOperation,
GraphQLOptions,
GraphQLResult,
} from '../types';
import { GraphQLOperation, GraphQLOptions, GraphQLResult } from '../types';
import { resolveConfig, resolveLibraryOptions } from '../utils';
import { repackageUnauthError } from '../utils/errors/repackageAuthError';
import { repackageUnauthorizedError } from '../utils/errors/repackageAuthError';
import {
NO_API_KEY,
NO_AUTH_TOKEN_HEADER,
NO_ENDPOINT,
NO_SIGNED_IN_USER,
NO_VALID_AUTH_TOKEN,
NO_VALID_CREDENTIALS,
} from '../utils/errors/constants';
import { GraphQLApiError, createGraphQLResultWithError } from '../utils/errors';

import { isGraphQLResponseWithErrors } from './utils/runtimeTypeGuards/isGraphQLResponseWithErrors';

const USER_AGENT_HEADER = 'x-amz-user-agent';

Expand Down Expand Up @@ -76,7 +81,7 @@ export class InternalGraphQLAPIClass {
switch (authMode) {
case 'apiKey':
if (!apiKey) {
throw new Error(GraphQLAuthError.NO_API_KEY);
throw new GraphQLApiError(NO_API_KEY);
}
headers = {
'X-Api-Key': apiKey,
Expand All @@ -85,33 +90,44 @@ export class InternalGraphQLAPIClass {
case 'iam': {
const session = await amplify.Auth.fetchAuthSession();
if (session.credentials === undefined) {
throw new Error(GraphQLAuthError.NO_CREDENTIALS);
throw new GraphQLApiError(NO_VALID_CREDENTIALS);
}
break;
}
case 'oidc':
case 'userPool':
case 'userPool': {
let token: string | undefined;

try {
const token = (
token = (
await amplify.Auth.fetchAuthSession()
).tokens?.accessToken.toString();

if (!token) {
throw new Error(GraphQLAuthError.NO_FEDERATED_JWT);
}
headers = {
Authorization: token,
};
} catch (e) {
throw new Error(GraphQLAuthError.NO_CURRENT_USER);
// fetchAuthSession failed
throw new GraphQLApiError({
...NO_SIGNED_IN_USER,
underlyingError: e,
});
}

// `fetchAuthSession()` succeeded but didn't return `tokens`.
// This may happen when unauthenticated access is enabled and there is
// no user signed in.
if (!token) {
throw new GraphQLApiError(NO_VALID_AUTH_TOKEN);
}

headers = {
Authorization: token,
};
break;
}
case 'lambda':
if (
typeof additionalHeaders === 'object' &&
!additionalHeaders.Authorization
) {
throw new Error(GraphQLAuthError.NO_AUTH_TOKEN);
throw new GraphQLApiError(NO_AUTH_TOKEN_HEADER);
}

headers = {
Expand Down Expand Up @@ -350,18 +366,15 @@ export class InternalGraphQLAPIClass {
const endpoint = customEndpoint || appSyncGraphqlEndpoint;

if (!endpoint) {
const error = new GraphQLError('No graphql endpoint provided.');
// TODO(Eslint): refactor this to throw an Error instead of a plain object
// eslint-disable-next-line no-throw-literal
throw {
data: {},
errors: [error],
};
throw createGraphQLResultWithError<T>(new GraphQLApiError(NO_ENDPOINT));
}

let response: any;

try {
// See the inline doc of the REST `post()` API for possible errors to be thrown.
// As these errors are catastrophic they should be caught and handled by GraphQL
// API consumers.
const { body: responseBody } = await this._api.post(amplify, {
url: new AmplifyUrl(endpoint),
options: {
Expand All @@ -373,39 +386,20 @@ export class InternalGraphQLAPIClass {
abortController,
});

const result = await responseBody.json();

response = result;
} catch (err) {
// If the exception is because user intentionally
// cancelled the request, do not modify the exception
// so that clients can identify the exception correctly.
if (this.isCancelError(err)) {
throw err;
response = await responseBody.json();
} catch (error) {
if (this.isCancelError(error)) {
throw error;
}

response = {
data: {},
errors: [
new GraphQLError(
(err as any).message,
null,
null,
null,
null,
err as any,
),
],
};
response = createGraphQLResultWithError<T>(error as any);
}

const { errors } = response;

if (errors && errors.length) {
throw repackageUnauthError(response);
if (isGraphQLResponseWithErrors(response)) {
throw repackageUnauthorizedError(response);
}

return response;
return response as unknown as GraphQLResult<T>;
}

/**
Expand Down Expand Up @@ -463,7 +457,7 @@ export class InternalGraphQLAPIClass {
.pipe(
catchError(e => {
if (e.errors) {
throw repackageUnauthError(e);
throw repackageUnauthorizedError(e);
}
throw e;
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { GraphQLResult } from '../../../types';

export function isGraphQLResponseWithErrors(
response: unknown,
): response is GraphQLResult {
if (!response) {
return false;
}
const r = response as GraphQLResult;

return Array.isArray(r.errors) && r.errors.length > 0;
}
Loading

0 comments on commit d4a48f1

Please sign in to comment.