diff --git a/packages/api-graphql/__tests__/GraphQLAPI.test.ts b/packages/api-graphql/__tests__/GraphQLAPI.test.ts index 43779fee5d6..ec553952499 100644 --- a/packages/api-graphql/__tests__/GraphQLAPI.test.ts +++ b/packages/api-graphql/__tests__/GraphQLAPI.test.ts @@ -1558,4 +1558,60 @@ describe('API test', () => { ); }); }); + + test('request level custom headers are applied to query string', async () => { + Amplify.configure({ + API: { + GraphQL: { + defaultAuthMode: 'lambda', + endpoint: + 'https://testaccounturl123456789123.appsync-api.us-east-1.amazonaws.com/graphql', + region: 'local-host-h4x', + }, + }, + }); + + let done: Function; + const mockedFnHasBeenCalled = new Promise(res => (done = res)); + + const spyon_appsync_realtime = jest + .spyOn( + AWSAppSyncRealTimeProvider.prototype as any, + '_initializeRetryableHandshake', + ) + .mockImplementation( + jest.fn(() => { + done(); // resolve promise when called + }) as any, + ); + + const query = /* GraphQL */ ` + subscription SubscribeToEventComments { + subscribeToEventComments { + eventId + } + } + `; + + const resolvedUrl = + 'wss://testaccounturl123456789123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJBdXRob3JpemF0aW9uIjoiYWJjMTIzNDUiLCJob3N0IjoidGVzdGFjY291bnR1cmwxMjM0NTY3ODkxMjMuYXBwc3luYy1hcGkudXMtZWFzdC0xLmFtYXpvbmF3cy5jb20ifQ==&payload=e30=&x-amz-user-agent=aws-amplify%2F6.4.0%20api%2F1%20framework%2F2&ex-machina=is%20a%20good%20movie'; + + ( + client.graphql( + { query }, + { + 'x-amz-user-agent': 'aws-amplify/6.4.0 api/1 framework/2', + 'ex-machina': 'is a good movie', + // This should NOT get included in the querystring + Authorization: 'abc12345', + }, + ) as unknown as Observable + ).subscribe(); + + await mockedFnHasBeenCalled; + + expect(spyon_appsync_realtime).toHaveBeenCalledTimes(1); + const subscribeOptions = spyon_appsync_realtime.mock.calls[0][0]; + expect(subscribeOptions).toBe(resolvedUrl); + }); }); diff --git a/packages/api-graphql/__tests__/internals/generateClient.test.ts b/packages/api-graphql/__tests__/internals/generateClient.test.ts index c5104997df1..94ba7bcafe4 100644 --- a/packages/api-graphql/__tests__/internals/generateClient.test.ts +++ b/packages/api-graphql/__tests__/internals/generateClient.test.ts @@ -3,7 +3,7 @@ import { Amplify, AmplifyClassV6 } from '@aws-amplify/core'; import { generateClient } from '../../src/internals'; import configFixture from '../fixtures/modeled/amplifyconfiguration'; import { Schema } from '../fixtures/modeled/schema'; -import { from } from 'rxjs'; +import { Observable, from } from 'rxjs'; import { normalizePostGraphqlCalls, expectSubWithHeaders, @@ -11,6 +11,7 @@ import { expectSubWithlibraryConfigHeaders, mockApiResponse, } from '../utils/index'; +import { AWSAppSyncRealTimeProvider } from '../../src/Providers/AWSAppSyncRealTimeProvider'; const serverManagedFields = { id: 'some-id', @@ -332,6 +333,30 @@ describe('generateClient', () => { expect(normalizePostGraphqlCalls(spy)).toMatchSnapshot(); }); + test('with custom client headers - graphql', async () => { + const headers = { + 'client-header': 'should exist', + }; + + const client = generateClient({ + amplify: Amplify, + headers, + }); + + await client.graphql({ + query: /* GraphQL */ ` + query listPosts { + id + } + `, + }); + + const receivedArgs = normalizePostGraphqlCalls(spy)[0][1]; + const receivedHeaders = receivedArgs.options.headers; + + expect(receivedHeaders).toEqual(expect.objectContaining(headers)); + }); + test('with custom client header functions', async () => { const client = generateClient({ amplify: Amplify, @@ -495,6 +520,39 @@ describe('generateClient', () => { }); }); + test('with client-level custom headers', done => { + const customHeaders = { + 'subscription-header': 'should-exist', + }; + + const client = generateClient({ + amplify: Amplify, + headers: customHeaders, + }); + + const spy = jest.fn(() => from([graphqlMessage])); + (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + + client.models.Note.onCreate({ + filter: graphqlVariables.filter, + }).subscribe({ + next(value) { + expectSubWithHeaders( + spy, + 'onCreateNote', + graphqlVariables, + customHeaders, + ); + expect(value).toEqual(expect.objectContaining(noteToSend)); + done(); + }, + error(error) { + expect(error).toBeUndefined(); + done('bad news!'); + }, + }); + }); + test('with a custom header function', done => { const customHeaders = { 'subscription-header-function': 'should-return-this-header', diff --git a/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts b/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts index 37cafbf719e..e25be1005d3 100644 --- a/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts @@ -712,6 +712,44 @@ export class AWSAppSyncRealTimeProvider { } } + /** + * Strips out `Authorization` header if present + */ + private _extractNonAuthHeaders( + headers?: AWSAppSyncRealTimeProviderOptions['additionalCustomHeaders'], + ): Record { + if (!headers) { + return {}; + } + + if ('Authorization' in headers) { + const { Authorization: _, ...nonAuthHeaders } = headers; + + return nonAuthHeaders; + } + + return headers; + } + + /** + * + * @param headers - http headers + * @returns query string of uri-encoded parameters derived from custom headers + */ + private _queryStringFromCustomHeaders( + headers?: AWSAppSyncRealTimeProviderOptions['additionalCustomHeaders'], + ): string { + const nonAuthHeaders = this._extractNonAuthHeaders(headers); + + const queryParams: string[] = Object.entries(nonAuthHeaders).map( + ([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`, + ); + + const queryString = queryParams.join('&'); + + return queryString; + } + private _initializeWebSocketConnection({ appSyncGraphqlEndpoint, authenticationType, @@ -749,6 +787,10 @@ export class AWSAppSyncRealTimeProvider { const payloadQs = base64Encoder.convert(payloadString); + const queryString = this._queryStringFromCustomHeaders( + additionalCustomHeaders, + ); + let discoverableEndpoint = appSyncGraphqlEndpoint ?? ''; if (this.isCustomDomain(discoverableEndpoint)) { @@ -766,7 +808,11 @@ export class AWSAppSyncRealTimeProvider { .replace('https://', protocol) .replace('http://', protocol); - const awsRealTimeUrl = `${discoverableEndpoint}?header=${headerQs}&payload=${payloadQs}`; + let awsRealTimeUrl = `${discoverableEndpoint}?header=${headerQs}&payload=${payloadQs}`; + + if (queryString !== '') { + awsRealTimeUrl += `&${queryString}`; + } await this._initializeRetryableHandshake(awsRealTimeUrl); diff --git a/packages/api-graphql/src/internals/v6.ts b/packages/api-graphql/src/internals/v6.ts index 0cdcf483927..c5d362908c8 100644 --- a/packages/api-graphql/src/internals/v6.ts +++ b/packages/api-graphql/src/internals/v6.ts @@ -106,6 +106,7 @@ export function graphql< const internals = getInternals(this as any); options.authMode = options.authMode || internals.authMode; options.authToken = options.authToken || internals.authToken; + const headers = additionalHeaders || internals.headers; /** * The correctness of these typings depends on correct string branding or overrides. @@ -116,7 +117,7 @@ export function graphql< // TODO: move V6Client back into this package? internals.amplify as any, options, - additionalHeaders, + headers, ); return result as any; diff --git a/packages/api-rest/__tests__/apis/common/internalPost.test.ts b/packages/api-rest/__tests__/apis/common/internalPost.test.ts index acfd9ae4530..f4887e9d2ad 100644 --- a/packages/api-rest/__tests__/apis/common/internalPost.test.ts +++ b/packages/api-rest/__tests__/apis/common/internalPost.test.ts @@ -91,6 +91,47 @@ describe('internal post', () => { expect.objectContaining({ region: 'us-west-2', service: 'lambda' }), ); }); + + it('should call authenticatedHandler for appsync-api service with default signing name', async () => { + const appsyncApiEndpoint = new URL( + 'https://123.appsync-api.us-west-2.amazonaws.com/graphql', + ); + await post(mockAmplifyInstance, { + url: appsyncApiEndpoint, + options: { + signingServiceInfo: { region: 'us-east-1' }, + }, + }); + expect(mockAuthenticatedHandler).toHaveBeenCalledWith( + { + url: appsyncApiEndpoint, + method: 'POST', + headers: {}, + }, + expect.objectContaining({ region: 'us-east-1', service: 'appsync' }), + ); + }); + + it('should call authenticatedHandler for appsync-api with specified service from signingServiceInfo', async () => { + const appsyncApiEndpoint = new URL( + 'https://123.appsync-api.us-west-2.amazonaws.com/graphql', + ); + await post(mockAmplifyInstance, { + url: appsyncApiEndpoint, + options: { + signingServiceInfo: { service: 'appsync', region: 'us-east-1' }, + }, + }); + expect(mockAuthenticatedHandler).toHaveBeenCalledWith( + { + url: appsyncApiEndpoint, + method: 'POST', + headers: {}, + }, + expect.objectContaining({ region: 'us-east-1', service: 'appsync' }), + ); + }); + it('should call authenticatedHandler with empty signingServiceInfo', async () => { await post(mockAmplifyInstance, { url: apiGatewayUrl, diff --git a/packages/api-rest/__tests__/utils/isIamAuthApplicable.test.ts b/packages/api-rest/__tests__/utils/isIamAuthApplicable.test.ts new file mode 100644 index 00000000000..a75733f738a --- /dev/null +++ b/packages/api-rest/__tests__/utils/isIamAuthApplicable.test.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HttpRequest } from '@aws-amplify/core/internals/aws-client-utils'; + +import { + isIamAuthApplicableForGraphQL, + isIamAuthApplicableForRest, +} from '../../src/utils/isIamAuthApplicable'; + +describe('iamAuthApplicable', () => { + const url = new URL('https://url'); + const baseRequest: HttpRequest = { + headers: {}, + url, + method: 'put', + }; + + describe('iamAuthApplicableForGraphQL', () => { + it('should return true if there is no authorization header, no x-api-key header, and signingServiceInfo is provided', () => { + const signingServiceInfo = {}; + expect( + isIamAuthApplicableForGraphQL(baseRequest, signingServiceInfo), + ).toBe(true); + }); + + it('should return false if there is an authorization header', () => { + const request = { + ...baseRequest, + headers: { authorization: 'SampleToken' }, + }; + const signingServiceInfo = {}; + expect(isIamAuthApplicableForGraphQL(request, signingServiceInfo)).toBe( + false, + ); + }); + + it('should return false if there is an x-api-key header', () => { + const request = { ...baseRequest, headers: { 'x-api-key': 'key' } }; + const signingServiceInfo = {}; + expect(isIamAuthApplicableForGraphQL(request, signingServiceInfo)).toBe( + false, + ); + }); + + it('should return false if signingServiceInfo is not provided', () => { + expect(isIamAuthApplicableForGraphQL(baseRequest)).toBe(false); + }); + }); + + describe('iamAuthApplicableForPublic', () => { + it('should return true if there is no authorization header and signingServiceInfo is provided', () => { + const signingServiceInfo = {}; + expect(isIamAuthApplicableForRest(baseRequest, signingServiceInfo)).toBe( + true, + ); + }); + + it('should return false if there is an authorization header', () => { + const request = { + ...baseRequest, + headers: { authorization: 'SampleToken' }, + }; + const signingServiceInfo = {}; + expect(isIamAuthApplicableForRest(request, signingServiceInfo)).toBe( + false, + ); + }); + + it('should return false if signingServiceInfo is not provided', () => { + expect(isIamAuthApplicableForRest(baseRequest)).toBe(false); + }); + }); +}); diff --git a/packages/api-rest/src/apis/common/handler.ts b/packages/api-rest/src/apis/common/handler.ts index 9ae021e4e4e..d17a8c72b6a 100644 --- a/packages/api-rest/src/apis/common/handler.ts +++ b/packages/api-rest/src/apis/common/handler.ts @@ -20,7 +20,7 @@ import { parseSigningInfo, } from '../../utils'; import { resolveHeaders } from '../../utils/resolveHeaders'; -import { RestApiResponse } from '../../types'; +import { RestApiResponse, SigningServiceInfo } from '../../types'; type HandlerOptions = Omit & { body?: DocumentType | FormData; @@ -28,11 +28,6 @@ type HandlerOptions = Omit & { withCredentials?: boolean; }; -interface SigningServiceInfo { - service?: string; - region?: string; -} - /** * Make REST API call with best-effort IAM auth. * @param amplify Amplify instance to to resolve credentials and tokens. Should use different instance in client-side @@ -40,12 +35,17 @@ interface SigningServiceInfo { * @param options Options accepted from public API options when calling the handlers. * @param signingServiceInfo Internal-only options enable IAM auth as well as to to overwrite the IAM signing service * and region. If specified, and NONE of API Key header or Auth header is present, IAM auth will be used. + * @param iamAuthApplicable Callback function that is used to determine if IAM Auth should be used or not. * * @internal */ export const transferHandler = async ( amplify: AmplifyClassV6, options: HandlerOptions & { abortSignal: AbortSignal }, + iamAuthApplicable: ( + { headers }: HttpRequest, + signingServiceInfo?: SigningServiceInfo, + ) => boolean, signingServiceInfo?: SigningServiceInfo, ): Promise => { const { url, method, headers, body, withCredentials, abortSignal } = options; @@ -69,6 +69,7 @@ export const transferHandler = async ( }; const isIamAuthApplicable = iamAuthApplicable(request, signingServiceInfo); + let response: RestApiResponse; const credentials = await resolveCredentials(amplify); if (isIamAuthApplicable && credentials) { @@ -97,11 +98,6 @@ export const transferHandler = async ( }; }; -const iamAuthApplicable = ( - { headers }: HttpRequest, - signingServiceInfo?: SigningServiceInfo, -) => !headers.authorization && !headers['x-api-key'] && !!signingServiceInfo; - const resolveCredentials = async ( amplify: AmplifyClassV6, ): Promise => { diff --git a/packages/api-rest/src/apis/common/internalPost.ts b/packages/api-rest/src/apis/common/internalPost.ts index 835c3581d56..6dabea22072 100644 --- a/packages/api-rest/src/apis/common/internalPost.ts +++ b/packages/api-rest/src/apis/common/internalPost.ts @@ -6,6 +6,7 @@ import { AmplifyClassV6 } from '@aws-amplify/core'; import { InternalPostInput, RestApiResponse } from '../../types'; import { createCancellableOperation } from '../../utils'; import { CanceledError } from '../../errors'; +import { isIamAuthApplicableForGraphQL } from '../../utils/isIamAuthApplicable'; import { transferHandler } from './handler'; @@ -46,7 +47,7 @@ const cancelTokenMap = new WeakMap, AbortController>(); * @param postInput.abortController The abort controller used to cancel the POST request * @returns a {@link RestApiResponse} * - * @throws an {@link Error} with `Network error` as the `message` when the external resource is unreachable due to one + * @throws an {@link AmplifyError} with `Network Error` as the `message` when the external resource is unreachable due to one * of the following reasons: * 1. no network connection * 2. CORS error @@ -66,6 +67,7 @@ export const post = ( ...options, abortSignal: controller.signal, }, + isIamAuthApplicableForGraphQL, options?.signingServiceInfo, ); diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts index 6a132a6b277..8c7a58cb6fc 100644 --- a/packages/api-rest/src/apis/common/publicApis.ts +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -25,6 +25,7 @@ import { parseSigningInfo, resolveApiUrl, } from '../../utils'; +import { isIamAuthApplicableForRest } from '../../utils/isIamAuthApplicable'; import { transferHandler } from './handler'; @@ -71,6 +72,7 @@ const publicHandler = ( headers, abortSignal, }, + isIamAuthApplicableForRest, signingServiceInfo, ); }); diff --git a/packages/api-rest/src/errors/CanceledError.ts b/packages/api-rest/src/errors/CanceledError.ts index ce4082212e0..67278d62a7c 100644 --- a/packages/api-rest/src/errors/CanceledError.ts +++ b/packages/api-rest/src/errors/CanceledError.ts @@ -29,6 +29,9 @@ export class CanceledError extends RestApiError { * * @note This function works **ONLY** for errors thrown by REST API. For GraphQL APIs, use `client.isCancelError(error)` * instead. `client` is generated from `generateClient()` API from `aws-amplify/api`. + * + * @param {unknown} error The unknown exception to be checked. + * @returns - A boolean indicating if the error was from an upload cancellation */ export const isCancelError = (error: unknown): error is CanceledError => !!error && error instanceof CanceledError; diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index f2f3c214b17..7e0ecb61e7c 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -112,3 +112,12 @@ export interface InternalPostInput { */ abortController?: AbortController; } + +/** + * Type for signingServiceInfo which enable IAM auth as well as overwrite the IAM signing info. + * @internal + */ +export interface SigningServiceInfo { + service?: string; + region?: string; +} diff --git a/packages/api-rest/src/utils/isIamAuthApplicable.ts b/packages/api-rest/src/utils/isIamAuthApplicable.ts new file mode 100644 index 00000000000..ba48e7be3de --- /dev/null +++ b/packages/api-rest/src/utils/isIamAuthApplicable.ts @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HttpRequest } from '@aws-amplify/core/internals/aws-client-utils'; + +import { SigningServiceInfo } from '../types'; + +/** + * Determines if IAM authentication should be applied for a GraphQL request. + * + * This function checks the `headers` of the HTTP request to determine if IAM authentication + * is applicable. IAM authentication is considered applicable if there is no `authorization` + * header, no `x-api-key` header, and `signingServiceInfo` is provided. + * + * @param request - The HTTP request object containing headers. + * @param signingServiceInfo - Optional signing service information, + * including service and region. + * @returns A boolean `true` if IAM authentication should be applied. + * + * @internal + */ +export const isIamAuthApplicableForGraphQL = ( + { headers }: HttpRequest, + signingServiceInfo?: SigningServiceInfo, +) => !headers.authorization && !headers['x-api-key'] && !!signingServiceInfo; + +/** + * Determines if IAM authentication should be applied for a REST request. + * + * This function checks the `headers` of the HTTP request to determine if IAM authentication + * is applicable. IAM authentication is considered applicable if there is no `authorization` + * header and `signingServiceInfo` is provided. + * + * @param request - The HTTP request object containing headers. + * @param signingServiceInfo - Optional signing service information, + * including service and region. + * @returns A boolean `true` if IAM authentication should be applied. + * + * @internal + */ +export const isIamAuthApplicableForRest = ( + { headers }: HttpRequest, + signingServiceInfo?: SigningServiceInfo, +) => !headers.authorization && !!signingServiceInfo; diff --git a/packages/api/src/API.ts b/packages/api/src/API.ts index db0559e4477..8aee0fc3334 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -6,6 +6,9 @@ import { Amplify } from '@aws-amplify/core'; /** * Generates an API client that can work with models or raw GraphQL + * + * @returns {@link V6Client} + * @throws {@link Error} - Throws error when client cannot be generated due to configuration issues. */ export function generateClient = never>( options: CommonPublicClientOptions = {}, diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index bed69394a43..63497fb4a41 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,7 +293,7 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.18 kB" + "limit": "17.25 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.68 kB" + "limit": "15.75 kB" }, { "name": "[Analytics] enable", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.53 kB" + "limit": "12.55 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.47 kB" + "limit": "12.50 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.49 kB" + "limit": "12.50 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -389,19 +389,19 @@ "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "11.83 kB" + "limit": "11.85 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "11.86 kB" + "limit": "11.90 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.71 kB" + "limit": "12.75 kB" }, { "name": "[Auth] updatePassword (Cognito)", @@ -419,7 +419,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "11.95 kB" + "limit": "12.00 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,7 +431,7 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.71 kB" + "limit": "12.75 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", @@ -443,7 +443,7 @@ "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.77 kB" + "limit": "11.80 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", @@ -479,7 +479,7 @@ "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "15.90 kB" + "limit": "15.95 kB" }, { "name": "[Storage] list (S3)", @@ -497,7 +497,7 @@ "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "19.90 kB" + "limit": "19.95 kB" } ] } diff --git a/packages/core/package.json b/packages/core/package.json index b65cf5c7936..32072ec879a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,13 +82,13 @@ "name": "Custom clients (fetch handler)", "path": "./dist/esm/clients/handlers/fetch.mjs", "import": "{ fetchTransferHandler }", - "limit": "500 B" + "limit": "575 B" }, { "name": "Custom clients (unauthenticated handler)", "path": "./dist/esm/clients/handlers/unauthenticated.mjs", "import": "{ unauthenticatedHandler }", - "limit": "1 kB" + "limit": "1.15 kB" }, { "name": "Custom clients (request signer)", diff --git a/packages/core/src/clients/handlers/fetch.ts b/packages/core/src/clients/handlers/fetch.ts index be8e5ac9520..30a37f210e2 100644 --- a/packages/core/src/clients/handlers/fetch.ts +++ b/packages/core/src/clients/handlers/fetch.ts @@ -3,7 +3,9 @@ import { HttpRequest, HttpResponse, HttpTransferOptions } from '../types/http'; import { TransferHandler } from '../types/core'; +import { AmplifyError } from '../../errors'; import { withMemoization } from '../utils/memoization'; +import { AmplifyErrorCode } from '../../types'; const shouldSendBody = (method: string) => !['HEAD', 'GET', 'DELETE'].includes(method.toUpperCase()); @@ -28,11 +30,12 @@ export const fetchTransferHandler: TransferHandler< credentials: withCrossDomainCredentials ? 'include' : 'same-origin', }); } catch (e) { - // TODO: needs to revise error handling in v6 - // 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 AmplifyError({ + name: AmplifyErrorCode.NetworkError, + message: 'A network error has occurred.', + underlyingError: e, + }); } throw e; } diff --git a/packages/core/src/errors/errorHelpers.ts b/packages/core/src/errors/errorHelpers.ts index a343c51f3f2..cb78f5534c9 100644 --- a/packages/core/src/errors/errorHelpers.ts +++ b/packages/core/src/errors/errorHelpers.ts @@ -15,6 +15,9 @@ const amplifyErrorMap: AmplifyErrorMap = { [AmplifyErrorCode.Unknown]: { message: 'An unknown error occurred.', }, + [AmplifyErrorCode.NetworkError]: { + message: 'A network error has occurred.', + }, }; export const assert: AssertionFunction = diff --git a/packages/core/src/singleton/Auth/utils/index.ts b/packages/core/src/singleton/Auth/utils/index.ts index c710e64a1ee..496d28db68d 100644 --- a/packages/core/src/singleton/Auth/utils/index.ts +++ b/packages/core/src/singleton/Auth/utils/index.ts @@ -66,6 +66,12 @@ export function assertIdentityPoolIdConfig( ); } +/** + * Decodes payload of JWT token + * + * @param {String} token A string representing a token to be decoded + * @throws {@link Error} - Throws error when token is invalid or payload malformed. + */ export function decodeJWT(token: string): JWT { const tokenParts = token.split('.'); diff --git a/packages/core/src/singleton/apis/fetchAuthSession.ts b/packages/core/src/singleton/apis/fetchAuthSession.ts index f7e1248ae54..1e7d4aa5f04 100644 --- a/packages/core/src/singleton/apis/fetchAuthSession.ts +++ b/packages/core/src/singleton/apis/fetchAuthSession.ts @@ -6,6 +6,15 @@ import { AuthSession, FetchAuthSessionOptions } from '../Auth/types'; import { fetchAuthSession as fetchAuthSessionInternal } from './internal/fetchAuthSession'; +/** + * Fetch the auth session including the tokens and credentials if they are available. By default it + * does not refresh the auth tokens or credentials if they are loaded in storage already. You can force a refresh + * with `{ forceRefresh: true }` input. + * + * @param options - Options configuring the fetch behavior. + * @throws {@link AuthError} - Throws error when session information cannot be refreshed. + * @returns Promise + */ export const fetchAuthSession = ( options?: FetchAuthSessionOptions, ): Promise => { diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts index 19ecec76935..3401616074f 100644 --- a/packages/core/src/types/errors.ts +++ b/packages/core/src/types/errors.ts @@ -5,6 +5,7 @@ export enum AmplifyErrorCode { NoEndpointId = 'NoEndpointId', PlatformNotSupported = 'PlatformNotSupported', Unknown = 'Unknown', + NetworkError = 'NetworkError', } export interface AmplifyErrorParams { diff --git a/packages/datastore/src/types.ts b/packages/datastore/src/types.ts index 75e0b9ff27e..5cd8c6085c0 100644 --- a/packages/datastore/src/types.ts +++ b/packages/datastore/src/types.ts @@ -71,10 +71,20 @@ export interface SchemaModel { syncable?: boolean; } +/** + * @private + * @param obj + * @returns `true` if the given object likely represents a model in a schema. + */ export function isSchemaModel(obj: any): obj is SchemaModel { return obj && (obj as SchemaModel).pluralName !== undefined; } +/** + * @private + * @param m + * @returns `true` if the given schema entry defines Schema Model attributes. + */ export function isSchemaModelWithAttributes( m: SchemaModel | SchemaNonModel, ): m is SchemaModel { @@ -104,6 +114,11 @@ interface AssociatedWith { targetNames?: string[]; } +/** + * @private + * @param obj + * @returns `true` if the object is an `AssociatedWith` definition. + */ export function isAssociatedWith(obj: any): obj is AssociatedWith { return obj && obj.associatedWith; } @@ -114,6 +129,11 @@ interface TargetNameAssociation { targetNames?: string[]; } +/** + * @private + * @param obj + * @returns `true` if the given object specifies either `targetName` or `targetNames`. + */ export function isTargetNameAssociation( obj: any, ): obj is TargetNameAssociation { @@ -123,6 +143,13 @@ export function isTargetNameAssociation( interface FieldAssociation { connectionType: 'HAS_ONE' | 'BELONGS_TO' | 'HAS_MANY'; } + +/** + * @private + * @param obj + * @param fieldName + * @returns Truthy if the object has a `FieldAssociation` for the given `fieldName`. + */ export function isFieldAssociation( obj: any, fieldName: string, @@ -154,6 +181,11 @@ export interface ModelAttributeAuth { }; } +/** + * @private + * @param attr + * @returns `true` if the given attribute is an auth attribute with rules. + */ export function isModelAttributeAuth( attr: ModelAttribute, ): attr is ModelAttributeAuth { @@ -189,6 +221,11 @@ interface ModelAttributeCompositeKey { }; } +/** + * @private + * @param attr + * @returns `true` if the given attribute is a key field. + */ export function isModelAttributeKey( attr: ModelAttribute, ): attr is ModelAttributeKey { @@ -200,12 +237,22 @@ export function isModelAttributeKey( ); } +/** + * @private + * @param attr + * @returns `true` if the given attribute is a primary key, indicated by the key being unnamed. + */ export function isModelAttributePrimaryKey( attr: ModelAttribute, ): attr is ModelAttributePrimaryKey { return isModelAttributeKey(attr) && attr.properties.name === undefined; } +/** + * @private + * @param attr + * @returns `true` if the given attribute represents a composite key with multiple fields. + */ export function isModelAttributeCompositeKey( attr: ModelAttribute, ): attr is ModelAttributeCompositeKey { @@ -334,6 +381,10 @@ export interface AuthorizationRule { areSubscriptionsPublic: boolean; } +/** + * @private + * @returns `true` if the given field specifies a scalar type. + */ export function isGraphQLScalarType( obj: any, ): obj is keyof Omit< @@ -347,6 +398,12 @@ export interface ModelFieldType { model: string; modelConstructor?: ModelMeta; } + +/** + * @private + * @param obj + * @returns `true` if the given field specifies a Model. + */ export function isModelFieldType<_ extends PersistentModel>( obj: any, ): obj is ModelFieldType { @@ -359,6 +416,12 @@ export function isModelFieldType<_ extends PersistentModel>( export interface NonModelFieldType { nonModel: string; } + +/** + * @private + * @param obj + * @returns `true` if the given field specifies a custom non-model type. + */ export function isNonModelFieldType(obj: any): obj is NonModelFieldType { const typeField: keyof NonModelFieldType = 'nonModel'; if (obj && obj[typeField]) return true; @@ -369,6 +432,12 @@ export function isNonModelFieldType(obj: any): obj is NonModelFieldType { interface EnumFieldType { enum: string; } + +/** + * @private + * @param obj + * @returns `true` if the object is an `EnumFieldType`. + */ export function isEnumFieldType(obj: any): obj is EnumFieldType { const modelField: keyof EnumFieldType = 'enum'; if (obj && obj[modelField]) return true; @@ -648,6 +717,12 @@ export type IdentifierFieldOrIdentifierObject< M extends PersistentModelMetaData, > = Pick> | IdentifierFieldValue; +/** + * @private + * @param obj + * @param modelDefinition + * @returns `true` if the given item is an object that has all identifier fields populated. + */ export function isIdentifierObject( obj: any, modelDefinition: SchemaModel, @@ -772,12 +847,22 @@ export interface PredicatesGroup { predicates: (PredicateObject | PredicatesGroup)[]; } +/** + * @private + * @param obj + * @returns `true` if the given predicate field object, specifying an [in-]equality test against a field. + */ export function isPredicateObj( obj: any, ): obj is PredicateObject { return obj && (obj as PredicateObject).field !== undefined; } +/** + * @private + * @param obj + * @returns `true` if the given predicate object is a "group" like "and", "or", or "not". + */ export function isPredicateGroup( obj: any, ): obj is PredicatesGroup { @@ -1032,6 +1117,34 @@ type ConditionProducer> = ( ...args: A ) => A['length'] extends keyof Lookup ? Lookup[A['length']] : never; +/** + * Build an expression that can be used to filter which items of a given Model + * are synchronized down from the GraphQL service. E.g., + * + * ```ts + * import { DataStore, syncExpression } from 'aws-amplify/datastore'; + * import { Post, Comment } from './models'; + * + * + * DataStore.configure({ + * syncExpressions: [ + * syncExpression(Post, () => { + * return (post) => post.rating.gt(5); + * }), + * syncExpression(Comment, () => { + * return (comment) => comment.status.eq('active'); + * }) + * ] + * }); + * ``` + * + * When DataStore starts syncing, only Posts with `rating > 5` and Comments with + * `status === 'active'` will be synced down to the user's local store. + * + * @param modelConstructor The Model from the schema. + * @param conditionProducer A function that builds a condition object that can describe how to filter the model. + * @returns An sync expression object that can be attached to the DataStore `syncExpressions` configuration property. + */ export async function syncExpression< T extends PersistentModel, A extends Option, diff --git a/packages/storage/src/errors/CanceledError.ts b/packages/storage/src/errors/CanceledError.ts index 9388653432a..da069ab1f13 100644 --- a/packages/storage/src/errors/CanceledError.ts +++ b/packages/storage/src/errors/CanceledError.ts @@ -28,6 +28,8 @@ export class CanceledError extends StorageError { /** * Check if an error is caused by user calling `cancel()` on a upload/download task. If an overwriting error is * supplied to `task.cancel(errorOverwrite)`, this function will return `false`. + * @param {unknown} error The unknown exception to be checked. + * @returns - A boolean indicating if the error was from an upload cancellation */ export const isCancelError = (error: unknown): error is CanceledError => !!error && error instanceof CanceledError; diff --git a/packages/storage/src/providers/s3/utils/client/runtime/xhrTransferHandler.ts b/packages/storage/src/providers/s3/utils/client/runtime/xhrTransferHandler.ts index 371b4d8efa6..eabb0865e04 100644 --- a/packages/storage/src/providers/s3/utils/client/runtime/xhrTransferHandler.ts +++ b/packages/storage/src/providers/s3/utils/client/runtime/xhrTransferHandler.ts @@ -12,6 +12,7 @@ import { ConsoleLogger } from '@aws-amplify/core'; import { TransferProgressEvent } from '../../../../../types/common'; import { CanceledError } from '../../../../../errors/CanceledError'; +import { StorageError } from '../../../../../errors/StorageError'; import { ABORT_ERROR_CODE, @@ -80,10 +81,10 @@ export const xhrTransferHandler: TransferHandler< } xhr.addEventListener('error', () => { - const networkError = buildHandlerError( - NETWORK_ERROR_MESSAGE, - NETWORK_ERROR_CODE, - ); + const networkError = new StorageError({ + message: NETWORK_ERROR_MESSAGE, + name: NETWORK_ERROR_CODE, + }); logger.error(NETWORK_ERROR_MESSAGE); reject(networkError); xhr = null; // clean up request