Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(api-graphql): update error handling #13177

Merged
merged 1 commit into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This specific error was being swallowed by the catch clause. Rearranged the flow here to ensure both errors in this block to be able to surface.

}
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
Loading