diff --git a/packages/api-graphql/__tests__/GraphQLAPI.test.ts b/packages/api-graphql/__tests__/GraphQLAPI.test.ts index 29599a9cb81..f82182dd063 100644 --- a/packages/api-graphql/__tests__/GraphQLAPI.test.ts +++ b/packages/api-graphql/__tests__/GraphQLAPI.test.ts @@ -5,15 +5,19 @@ import { Amplify as AmplifyCore } from '@aws-amplify/core'; import * as typedQueries from './fixtures/with-types/queries'; import { expectGet } from './utils/expects'; -import { - __amplify, - GraphQLResult, - GraphQLAuthError, - V6Client, -} from '../src/types'; +import { __amplify, GraphQLResult, V6Client } from '../src/types'; import { GetThreadQuery } from './fixtures/with-types/API'; import { AWSAppSyncRealTimeProvider } from '../src/Providers/AWSAppSyncRealTimeProvider'; import { Observable, of } from 'rxjs'; +import { + NO_API_KEY, + NO_AUTH_TOKEN_HEADER, + NO_ENDPOINT, + NO_SIGNED_IN_USER, + NO_VALID_AUTH_TOKEN, + NO_VALID_CREDENTIALS, +} from '../src/utils/errors/constants'; +import { NetworkError } from '@aws-amplify/core/internals/utils'; const serverManagedFields = { id: 'some-id', @@ -54,6 +58,9 @@ jest.mock('aws-amplify', () => { return mockedModule; }); +const mockFetchAuthSession = (Amplify as any).Auth + .fetchAuthSession as jest.Mock; + const client = { [__amplify]: Amplify, graphql, @@ -762,8 +769,7 @@ describe('API test', () => { }); test('multi-auth default case api-key, OIDC as auth mode, but no federatedSign', async () => { - const prevMockAccessToken = mockAccessToken; - mockAccessToken = null; + mockFetchAuthSession.mockRejectedValueOnce(new Error('some error')); Amplify.configure({ API: { @@ -805,10 +811,7 @@ describe('API test', () => { variables: graphqlVariables, authMode: 'oidc', }), - ).rejects.toThrow('No current user'); - - // Cleanup: - mockAccessToken = prevMockAccessToken; + ).rejects.toThrow(NO_SIGNED_IN_USER.message); }); test('multi-auth using CUP as auth mode, but no userpool', async () => { @@ -882,7 +885,7 @@ describe('API test', () => { variables: graphqlVariables, authMode: 'lambda', }), - ).rejects.toThrow(GraphQLAuthError.NO_AUTH_TOKEN); + ).rejects.toThrow(NO_AUTH_TOKEN_HEADER.message); }); test('multi-auth using API_KEY as auth mode, but no api-key configured', async () => { @@ -904,7 +907,7 @@ describe('API test', () => { variables: graphqlVariables, authMode: 'apiKey', }), - ).rejects.toThrow(GraphQLAuthError.NO_API_KEY); + ).rejects.toThrow(NO_API_KEY.message); }); test('multi-auth using AWS_IAM as auth mode, but no credentials', async () => { @@ -930,7 +933,7 @@ describe('API test', () => { variables: graphqlVariables, authMode: 'iam', }), - ).rejects.toThrow(GraphQLAuthError.NO_CREDENTIALS); + ).rejects.toThrow(NO_VALID_CREDENTIALS.message); // Cleanup: mockCredentials = prevMockCredentials; @@ -1342,5 +1345,77 @@ describe('API test', () => { }, ); }); + + test('throws error when endpoint is not configured', () => { + 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.toThrow(NO_ENDPOINT.message); + }); + + test('throws error when fetchAuthSession completes without errors but returns no tokens (unauthenticated access is enabled)', () => { + mockFetchAuthSession.mockResolvedValueOnce({ + tokens: undefined, + }); + + 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.toThrow(NO_VALID_AUTH_TOKEN.message); + }); + + test('rethrow api-rest post() API thrown NetworkError', () => { + jest + .spyOn((raw.GraphQLAPI as any)._api, 'post') + .mockRejectedValueOnce(new NetworkError('Network error')); + + 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.toThrow('Network error'); + }); }); }); diff --git a/packages/api-graphql/__tests__/internals/generateClient.test.ts b/packages/api-graphql/__tests__/internals/generateClient.test.ts index 233bddb64f2..7584646f1ba 100644 --- a/packages/api-graphql/__tests__/internals/generateClient.test.ts +++ b/packages/api-graphql/__tests__/internals/generateClient.test.ts @@ -5585,14 +5585,11 @@ describe('generateClient', () => { amplify: Amplify, }); - const { data, errors } = await client.queries.echo({ - argumentContent: 'echo argumentContent value', - }); - - // TODO: data should actually be null/undefined, pending discussion and fix. - // This is not strictly related to custom ops. - expect(data).toEqual({}); - expect(errors).toEqual([{ message: 'Network error' }]); + expect(() => + client.queries.echo({ + argumentContent: 'echo argumentContent value', + }), + ).rejects.toThrow('Network error'); }); test('core error handling', async () => { diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index fd3f276b2b6..4c2309c3a34 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { DocumentNode, - GraphQLError, OperationDefinitionNode, OperationTypeNode, parse, @@ -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 } from '../utils/errors'; + +import { isGraphQLResponseWithErrors } from './utils/runtimeTypeGuards/isGraphQLResponseWithErrors'; const USER_AGENT_HEADER = 'x-amz-user-agent'; @@ -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, @@ -85,33 +90,40 @@ 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); + throw new GraphQLApiError({ + ...NO_SIGNED_IN_USER, + underlyingError: e, + }); + } + + 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 = { @@ -350,62 +362,30 @@ 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 new GraphQLApiError(NO_ENDPOINT); } - let response: any; - - try { - const { body: responseBody } = await this._api.post(amplify, { - url: new AmplifyUrl(endpoint), - options: { - headers, - body, - signingServiceInfo, - withCredentials, - }, - 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 = { - data: {}, - errors: [ - new GraphQLError( - (err as any).message, - null, - null, - null, - null, - err as any, - ), - ], - }; - } - - const { errors } = response; - - if (errors && errors.length) { - throw repackageUnauthError(response); + // 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: { + headers, + body, + signingServiceInfo, + withCredentials, + }, + abortController, + }); + + const response = await responseBody.json(); + + if (isGraphQLResponseWithErrors(response)) { + throw repackageUnauthorizedError(response); } - return response; + return response as unknown as GraphQLResult; } /** @@ -463,7 +443,7 @@ export class InternalGraphQLAPIClass { .pipe( catchError(e => { if (e.errors) { - throw repackageUnauthError(e); + throw repackageUnauthorizedError(e); } throw e; }), diff --git a/packages/api-graphql/src/internals/utils/runtimeTypeGuards/isGraphQLResponseWithErrors.ts b/packages/api-graphql/src/internals/utils/runtimeTypeGuards/isGraphQLResponseWithErrors.ts new file mode 100644 index 00000000000..64efa23a2cb --- /dev/null +++ b/packages/api-graphql/src/internals/utils/runtimeTypeGuards/isGraphQLResponseWithErrors.ts @@ -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; +} diff --git a/packages/api-graphql/src/utils/errors/constants.ts b/packages/api-graphql/src/utils/errors/constants.ts new file mode 100644 index 00000000000..77273020ce1 --- /dev/null +++ b/packages/api-graphql/src/utils/errors/constants.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GraphQLAuthError } from '../../types'; + +export const NO_API_KEY = { + name: 'NoAPIKey', + message: GraphQLAuthError.NO_API_KEY, + recoverySuggestion: + 'The API request was made with `authMode: "apiKey"` but no API Key was passed into `Amplify.configure()`. Review if your API key is passed into the `Amplify.configure()` function.', +}; + +export const NO_VALID_CREDENTIALS = { + name: 'NoCredentials', + message: GraphQLAuthError.NO_CREDENTIALS, + recoverySuggestion: `The API request was made with \`authMode: "iam"\` but no authentication credentials are available. + +If you intended to make a request using an authenticated role, review if your user is signed in before making the request. + +If you intend to make a request using an unauthenticated role or also known as "guest access", verify if "Auth.Cognito.allowGuestAccess" is set to "true" in the \`Amplify.configure()\` function.`, +}; + +export const NO_VALID_AUTH_TOKEN = { + name: 'NoAuthTokens', + message: GraphQLAuthError.NO_FEDERATED_JWT, + recoverySuggestion: + 'If you intended to make an authenticated API request, review if the current user is signed in.', +}; + +export const NO_SIGNED_IN_USER = { + name: 'NoSignedUser', + message: GraphQLAuthError.NO_CURRENT_USER, + recoverySuggestion: + 'Review the underlying exception field for more details. If you intended to make an authenticated API request, review if the current user is signed in.', +}; + +export const NO_AUTH_TOKEN_HEADER = { + name: 'NoAuthorizationHeader', + message: GraphQLAuthError.NO_AUTH_TOKEN, + recoverySuggestion: + 'The API request was made with `authMode: "lambda"` but no `authToken` is set. Review if a valid authToken is passed into the request options or in the `Amplify.configure()` function.', +}; + +export const NO_ENDPOINT = { + name: 'NoEndpoint', + message: 'No GraphQL endpoint configured in `Amplify.configure()`.', + recoverySuggestion: + 'Review if the GraphQL API endpoint is set in the `Amplify.configure()` function.', +}; diff --git a/packages/api-graphql/src/utils/errors/repackageAuthError.ts b/packages/api-graphql/src/utils/errors/repackageAuthError.ts index e179c67436d..2a92c1f2f96 100644 --- a/packages/api-graphql/src/utils/errors/repackageAuthError.ts +++ b/packages/api-graphql/src/utils/errors/repackageAuthError.ts @@ -1,23 +1,21 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AmplifyErrorParams } from '@aws-amplify/core/internals/utils'; - -interface ErrorObject { - errors: AmplifyErrorParams[]; -} +import { GraphQLResult } from '../../types'; /** * Checks to see if the given response or subscription message contains an - * unauth error. If it does, it changes the error message to include instructions + * Unauthorized error. If it does, it changes the error message to include instructions * for the app developer. */ -export function repackageUnauthError(content: T): T { +export function repackageUnauthorizedError>( + content: T, +): T { if (content.errors && Array.isArray(content.errors)) { content.errors.forEach(e => { - if (isUnauthError(e)) { + if (isUnauthorizedError(e)) { e.message = 'Unauthorized'; - e.recoverySuggestion = + (e as any).recoverySuggestion = `If you're calling an Amplify-generated API, make sure ` + `to set the "authMode" in generateClient({ authMode: '...' }) to the backend authorization ` + `rule's auth provider ('apiKey', 'userPool', 'iam', 'oidc', 'lambda')`; @@ -28,7 +26,7 @@ export function repackageUnauthError(content: T): T { return content; } -function isUnauthError(error: any): boolean { +function isUnauthorizedError(error: any): boolean { // Error pattern corresponding to appsync calls if (error?.originalError?.name?.startsWith('UnauthorizedException')) { return true; diff --git a/packages/api-graphql/tsconfig.json b/packages/api-graphql/tsconfig.json index 11d9d83dd6a..4b13ce99ea9 100644 --- a/packages/api-graphql/tsconfig.json +++ b/packages/api-graphql/tsconfig.json @@ -2,12 +2,14 @@ "extends": "../../tsconfig.json", "compilerOptions": { "strictNullChecks": true, - "baseUrl": ".", "paths": { "@aws-amplify/data-schema-types": [ "../../node_modules/@aws-amplify/data-schema-types" ] } }, - "include": ["./src", "__tests__"] + "include": [ + "./src", + "__tests__" + ] } diff --git a/packages/api-rest/src/apis/common/internalPost.ts b/packages/api-rest/src/apis/common/internalPost.ts index 4f7a6ccc416..1e23c752046 100644 --- a/packages/api-rest/src/apis/common/internalPost.ts +++ b/packages/api-rest/src/apis/common/internalPost.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { AmplifyClassV6 } from '@aws-amplify/core'; +import { NetworkError } from '@aws-amplify/core/internals/utils'; import { InternalPostInput, RestApiResponse } from '../../types'; import { createCancellableOperation } from '../../utils'; +import { CanceledError } from '../../errors'; import { transferHandler } from './handler'; @@ -23,6 +25,32 @@ const cancelTokenMap = new WeakMap, AbortController>(); /** * @internal + * + * REST POST handler to send GraphQL request to given endpoint. By default, it will use IAM to authorize + * the request. In some auth modes, the IAM auth has to be disabled. Here's how to set up the request auth correctly: + * * If auth mode is 'iam', you MUST NOT set 'authorization' header and 'x-api-key' header, since it would disable IAM + * auth. You MUST also set 'input.options.signingServiceInfo' option. + * * The including 'input.options.signingServiceInfo.service' and 'input.options.signingServiceInfo.region' are + * optional. If omitted, the signing service and region will be inferred from url. + * * If auth mode is 'none', you MUST NOT set 'options.signingServiceInfo' option. + * * If auth mode is 'apiKey', you MUST set 'x-api-key' custom header. + * * If auth mode is 'oidc' or 'lambda' or 'userPool', you MUST set 'authorization' header. + * + * To make the internal post cancellable, you must also call `updateRequestToBeCancellable()` with the promise from + * internal post call and the abort controller supplied to the internal post call. + * + * @param amplify the AmplifyClassV6 instance - it may be the singleton used on Web, or an instance created within + * a context created by `runWithAmplifyServerContext` + * @param postInput an object of {@link InternalPostInput} + * @param postInput.url The URL that the POST request sends to + * @param postInput.options Options of the POST request + * @param postInput.abortController The abort controller used to cancel the POST request + * @returns a {@link RestApiResponse} + * + * @throws a {@link NetworkError} when the external resource is unreachable due to one of the following reasons: + * 1. no network connection + * 2. CORS error + * @throws a {@link CanceledError} when the ongoing POST request get cancelled */ export const post = ( amplify: AmplifyClassV6, diff --git a/packages/api-rest/src/internals/index.ts b/packages/api-rest/src/internals/index.ts index 3ce95df5371..78ef4399d0f 100644 --- a/packages/api-rest/src/internals/index.ts +++ b/packages/api-rest/src/internals/index.ts @@ -1,26 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { post as internalPost } from '../apis/common/internalPost'; - -/** - * Internal-only REST POST handler to send GraphQL request to given endpoint. By default, it will use IAM to authorize - * the request. In some auth modes, the IAM auth has to be disabled. Here's how to set up the request auth correctly: - * * If auth mode is 'iam', you MUST NOT set 'authorization' header and 'x-api-key' header, since it would disable IAM - * auth. You MUST also set 'input.options.signingServiceInfo' option. - * * The including 'input.options.signingServiceInfo.service' and 'input.options.signingServiceInfo.region' are - * optional. If omitted, the signing service and region will be inferred from url. - * * If auth mode is 'none', you MUST NOT set 'options.signingServiceInfo' option. - * * If auth mode is 'apiKey', you MUST set 'x-api-key' custom header. - * * If auth mode is 'oidc' or 'lambda' or 'userPool', you MUST set 'authorization' header. - * - * To make the internal post cancellable, you must also call `updateRequestToBeCancellable()` with the promise from - * internal post call and the abort controller supplied to the internal post call. - * - * @internal - */ -export const post = internalPost; - +export { post } from '../apis/common/internalPost'; export { cancel, updateRequestToBeCancellable, diff --git a/packages/api-rest/src/utils/createCancellableOperation.ts b/packages/api-rest/src/utils/createCancellableOperation.ts index f75010faabe..f2ec4b5c714 100644 --- a/packages/api-rest/src/utils/createCancellableOperation.ts +++ b/packages/api-rest/src/utils/createCancellableOperation.ts @@ -67,6 +67,8 @@ export function createCancellableOperation( const canceledError = new CanceledError({ ...(message && { message }), underlyingError: error, + recoverySuggestion: + 'The API request was explicitly canceled. If this is not intended, validate if you called the `cancel()` function on the API request erroneously.', }); logger.debug(error); throw canceledError; diff --git a/packages/core/src/clients/handlers/fetch.ts b/packages/core/src/clients/handlers/fetch.ts index be8e5ac9520..698cf229a2b 100644 --- a/packages/core/src/clients/handlers/fetch.ts +++ b/packages/core/src/clients/handlers/fetch.ts @@ -4,6 +4,7 @@ import { HttpRequest, HttpResponse, HttpTransferOptions } from '../types/http'; import { TransferHandler } from '../types/core'; import { withMemoization } from '../utils/memoization'; +import { NetworkError } from '../../errors/NetworkError'; const shouldSendBody = (method: string) => !['HEAD', 'GET', 'DELETE'].includes(method.toUpperCase()); @@ -32,7 +33,7 @@ export const fetchTransferHandler: TransferHandler< // For now this is a thin wrapper over original fetch error similar to cognito-identity-js package. // Ref: https://github.com/aws-amplify/amplify-js/blob/4fbc8c0a2be7526aab723579b4c95b552195a80b/packages/amazon-cognito-identity-js/src/Client.js#L103-L108 if (e instanceof TypeError) { - throw new Error('Network error'); + throw new NetworkError('Network error'); } throw e; } diff --git a/packages/core/src/errors/NetworkError.ts b/packages/core/src/errors/NetworkError.ts new file mode 100644 index 00000000000..73cc3ee1016 --- /dev/null +++ b/packages/core/src/errors/NetworkError.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export class NetworkError extends Error {} diff --git a/packages/core/src/errors/index.ts b/packages/core/src/errors/index.ts index 9c973f3907b..7d233254187 100644 --- a/packages/core/src/errors/index.ts +++ b/packages/core/src/errors/index.ts @@ -6,3 +6,4 @@ export { ApiError, ApiErrorParams, ApiErrorResponse } from './APIError'; export { createAssertionFunction } from './createAssertionFunction'; export { PlatformNotSupportedError } from './PlatformNotSupportedError'; export { assert } from './errorHelpers'; +export { NetworkError } from './NetworkError'; diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index a11eb0cf1c4..ad9cb3377c4 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -103,6 +103,7 @@ export { ApiError, ApiErrorParams, ApiErrorResponse, + NetworkError, } from './errors'; export { AmplifyErrorCode,