From db28490b9977e61be452863673fb2ca26cb0c7ac Mon Sep 17 00:00:00 2001 From: Jim Blanchard Date: Tue, 10 Oct 2023 12:16:44 -0500 Subject: [PATCH] feat: Added internal APIs for setting custom User Agent state (#12249) --- packages/aws-amplify/package.json | 52 ++++++------ .../Platform/customUserAgent.test.ts | 79 +++++++++++++++++++ .../userAgent.test.ts} | 72 ++++++++++++++--- packages/core/src/Platform/customUserAgent.ts | 75 ++++++++++++++++++ packages/core/src/Platform/index.ts | 15 +++- packages/core/src/Platform/types.ts | 45 ++++++++++- packages/core/src/libraryUtils.ts | 4 +- 7 files changed, 301 insertions(+), 41 deletions(-) create mode 100644 packages/core/__tests__/Platform/customUserAgent.test.ts rename packages/core/__tests__/{Platform.test.ts => Platform/userAgent.test.ts} (55%) create mode 100644 packages/core/src/Platform/customUserAgent.ts diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 53389803a5a..28573ac5087 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -252,31 +252,31 @@ "name": "[Analytics] record (Pinpoint)", "path": "./lib-esm/analytics/index.js", "import": "{ record }", - "limit": "21.62 kB" + "limit": "21.69 kB" }, { "name": "[Analytics] record (Kinesis)", "path": "./lib-esm/analytics/kinesis/index.js", "import": "{ record }", - "limit": "46.89 kB" + "limit": "46.96 kB" }, { "name": "[Analytics] record (Kinesis Firehose)", "path": "./lib-esm/analytics/kinesis-firehose/index.js", "import": "{ record }", - "limit": "43.23 kB" + "limit": "43.31 kB" }, { "name": "[Analytics] record (Personalize)", "path": "./lib-esm/analytics/personalize/index.js", "import": "{ record }", - "limit": "47.50 kB" + "limit": "47.59 kB" }, { "name": "[Analytics] identifyUser (Pinpoint)", "path": "./lib-esm/analytics/index.js", "import": "{ identifyUser }", - "limit": "19.72 kB" + "limit": "19.79 kB" }, { "name": "[Analytics] enable", @@ -312,13 +312,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ resetPassword }", - "limit": "11.7 kB" + "limit": "11.77 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ confirmResetPassword }", - "limit": "11.64 kB" + "limit": "11.70 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -330,7 +330,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ resendSignUpCode }", - "limit": "11.66 kB" + "limit": "11.73 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -342,31 +342,31 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ confirmSignIn }", - "limit": "29.6 kB" + "limit": "29.65 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ updateMFAPreference }", - "limit": "10.75 kB" + "limit": "10.82 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ fetchMFAPreference }", - "limit": "10.79 kB" + "limit": "10.85 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ verifyTOTPSetup }", - "limit": "11.68 kB" + "limit": "11.74 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ updatePassword }", - "limit": "11.66 kB" + "limit": "11.73 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -378,7 +378,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ updateUserAttributes }", - "limit": "10.93 kB" + "limit": "11.0 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -390,73 +390,73 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ confirmUserAttribute }", - "limit": "11.69 kB" + "limit": "11.76 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ signInWithRedirect }", - "limit": "22.87 kB" + "limit": "22.94 kB" }, { "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ fetchUserAttributes }", - "limit": "10.78 kB" + "limit": "10.85 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "31.88 kB" + "limit": "31.92 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "23.36 kB" + "limit": "23.42 kB" }, { "name": "[Storage] copy (S3)", "path": "./lib-esm/storage/index.js", "import": "{ copy }", - "limit": "17.88 kB" + "limit": "17.95 kB" }, { "name": "[Storage] downloadData (S3)", "path": "./lib-esm/storage/index.js", "import": "{ downloadData }", - "limit": "18.24 kB" + "limit": "18.30 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./lib-esm/storage/index.js", "import": "{ getProperties }", - "limit": "17.52 kB" + "limit": "17.6 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./lib-esm/storage/index.js", "import": "{ getUrl }", - "limit": "18.96 kB" + "limit": "19.03 kB" }, { "name": "[Storage] list (S3)", "path": "./lib-esm/storage/index.js", "import": "{ list }", - "limit": "18.05 kB" + "limit": "18.12 kB" }, { "name": "[Storage] remove (S3)", "path": "./lib-esm/storage/index.js", "import": "{ remove }", - "limit": "17.36 kB" + "limit": "17.43 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./lib-esm/storage/index.js", "import": "{ uploadData }", - "limit": "24.16 kB" + "limit": "24.22 kB" } ], "jest": { diff --git a/packages/core/__tests__/Platform/customUserAgent.test.ts b/packages/core/__tests__/Platform/customUserAgent.test.ts new file mode 100644 index 00000000000..913872eb6db --- /dev/null +++ b/packages/core/__tests__/Platform/customUserAgent.test.ts @@ -0,0 +1,79 @@ +import { + Category, + AuthAction, + StorageAction, + SetCustomUserAgentInput, +} from '../../src/Platform/types'; + +const MOCK_AUTH_UA_STATE: SetCustomUserAgentInput = { + category: Category.Auth, + apis: [AuthAction.ConfirmSignIn, AuthAction.SignIn], + additionalDetails: [['uastate', 'auth']], +}; + +const MOCK_STORAGE_UA_STATE: SetCustomUserAgentInput = { + category: Category.Storage, + apis: [StorageAction.Copy], + additionalDetails: [['uastate', 'storage']], +}; + +describe('Custom user agent utilities', () => { + let getCustomUserAgent; + let setCustomUserAgent; + + beforeEach(() => { + jest.resetModules(); + getCustomUserAgent = + require('../../src/Platform/customUserAgent').getCustomUserAgent; + setCustomUserAgent = + require('../../src/Platform/customUserAgent').setCustomUserAgent; + }); + + it('sets custom user agent state for multiple categories and APIs', () => { + setCustomUserAgent(MOCK_AUTH_UA_STATE); + setCustomUserAgent(MOCK_STORAGE_UA_STATE); + + const confirmSignInState = getCustomUserAgent( + Category.Auth, + AuthAction.ConfirmSignIn + ); + const signInState = getCustomUserAgent(Category.Auth, AuthAction.SignIn); + const copyState = getCustomUserAgent(Category.Storage, StorageAction.Copy); + + expect(copyState).toEqual([['uastate', 'storage']]); + expect(confirmSignInState).toStrictEqual([['uastate', 'auth']]); + expect(signInState).toEqual(confirmSignInState); + }); + + it('returns a callback that will clear user agent state', () => { + const cleanUp = setCustomUserAgent(MOCK_AUTH_UA_STATE); + const cleanUp2 = setCustomUserAgent(MOCK_AUTH_UA_STATE); + const cleanUp3 = setCustomUserAgent(MOCK_STORAGE_UA_STATE); + + // Setting state for the same category & API twice should prevent deletion until all references are cleaned up + cleanUp(); + let confirmSignInState = getCustomUserAgent( + Category.Auth, + AuthAction.ConfirmSignIn + ); + expect(confirmSignInState).toStrictEqual([['uastate', 'auth']]); + + cleanUp2(); + confirmSignInState = getCustomUserAgent( + Category.Auth, + AuthAction.ConfirmSignIn + ); + expect(confirmSignInState).toStrictEqual(undefined); + + // Calling the same cleanup callback twice shouldn't result in errors + cleanUp(); + + // Cleaning up shouldn't impact state set in a different call + let copyState = getCustomUserAgent(Category.Storage, StorageAction.Copy); + expect(copyState).toEqual([['uastate', 'storage']]); + + cleanUp3(); + copyState = getCustomUserAgent(Category.Storage, StorageAction.Copy); + expect(copyState).toStrictEqual(undefined); + }); +}); diff --git a/packages/core/__tests__/Platform.test.ts b/packages/core/__tests__/Platform/userAgent.test.ts similarity index 55% rename from packages/core/__tests__/Platform.test.ts rename to packages/core/__tests__/Platform/userAgent.test.ts index 8198ff1f888..0780e7b9c2c 100644 --- a/packages/core/__tests__/Platform.test.ts +++ b/packages/core/__tests__/Platform/userAgent.test.ts @@ -2,13 +2,26 @@ import { getAmplifyUserAgentObject, getAmplifyUserAgent, Platform, -} from '../src/Platform'; -import { version } from '../src/Platform/version'; -import { ApiAction, Category, Framework } from '../src/Platform/types'; -import { detectFramework, clearCache } from '../src/Platform/detectFramework'; -import * as detection from '../src/Platform/detection'; +} from '../../src/Platform'; +import { version } from '../../src/Platform/version'; +import { + ApiAction, + AuthAction, + Category, + Framework, +} from '../../src/Platform/types'; +import { + detectFramework, + clearCache, +} from '../../src/Platform/detectFramework'; +import * as detection from '../../src/Platform/detection'; +import { getCustomUserAgent } from '../../src/Platform/customUserAgent'; + +jest.mock('../../src/Platform/customUserAgent'); describe('Platform test', () => { + const mockGetCustomUserAgent = getCustomUserAgent as jest.Mock; + beforeAll(() => { jest.useFakeTimers(); }); @@ -18,6 +31,7 @@ describe('Platform test', () => { }); beforeEach(() => { + mockGetCustomUserAgent.mockReset(); clearCache(); }); @@ -39,13 +53,32 @@ describe('Platform test', () => { test('with customUserAgentDetails', () => { expect( getAmplifyUserAgentObject({ - category: Category.API, - action: ApiAction.None, + category: Category.Auth, + action: AuthAction.ConfirmSignIn, + }) + ).toStrictEqual([ + ['aws-amplify', version], + [Category.Auth, AuthAction.ConfirmSignIn], + ['framework', Framework.WebUnknown], + ]); + }); + + it('injects global user agent details when available', () => { + const mockUAState = [['uiversion', '1.0.0'], ['flag']]; + + mockGetCustomUserAgent.mockReturnValue(mockUAState); + + expect( + getAmplifyUserAgentObject({ + category: Category.Auth, + action: AuthAction.ConfirmSignIn, }) ).toStrictEqual([ ['aws-amplify', version], - [Category.API, ApiAction.None], + [Category.Auth, AuthAction.ConfirmSignIn], ['framework', Framework.WebUnknown], + ['uiversion', '1.0.0'], + ['flag'], ]); }); }); @@ -60,11 +93,26 @@ describe('Platform test', () => { test('with customUserAgentDetails', () => { expect( getAmplifyUserAgent({ - category: Category.API, - action: ApiAction.None, + category: Category.Auth, + action: AuthAction.ConfirmSignIn, + }) + ).toBe( + `${Platform.userAgent} ${Category.Auth}/${AuthAction.ConfirmSignIn} framework/${Framework.WebUnknown}` + ); + }); + + it('handles flag UA attributes', () => { + const mockUAState = [['uiversion', '1.0.0'], ['flag']]; + + mockGetCustomUserAgent.mockReturnValue(mockUAState); + + expect( + getAmplifyUserAgent({ + category: Category.Auth, + action: AuthAction.ConfirmSignIn, }) ).toBe( - `${Platform.userAgent} ${Category.API}/${ApiAction.None} framework/${Framework.WebUnknown}` + `${Platform.userAgent} ${Category.Auth}/${AuthAction.ConfirmSignIn} framework/${Framework.WebUnknown} uiversion/1.0.0 flag` ); }); }); @@ -86,7 +134,7 @@ describe('detectFramework observers', () => { beforeAll(() => { jest.resetModules(); - module = require('../src/Platform/detectFramework'); + module = require('../../src/Platform/detectFramework'); jest.useFakeTimers(); }); diff --git a/packages/core/src/Platform/customUserAgent.ts b/packages/core/src/Platform/customUserAgent.ts new file mode 100644 index 00000000000..a62bf6b9fcc --- /dev/null +++ b/packages/core/src/Platform/customUserAgent.ts @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AdditionalDetails, + CategoryUserAgentStateMap, + CustomUserAgentStateMap, + SetCustomUserAgentInput, +} from './types'; + +// Maintains custom user-agent state set by external consumers. +const customUserAgentState: CustomUserAgentStateMap = {}; + +/** + * Sets custom user agent state which will be appended to applicable requests. Returns a function that can be used to + * clean up any custom state set with this API. + * + * @note + * This API operates globally. Calling this API multiple times will result in the most recently set values for a + * particular API being used. + * + * @note + * This utility IS NOT compatible with SSR. + * + * @param input - SetCustomUserAgentInput that defines custom state to apply to the specified APIs. + */ +export const setCustomUserAgent = ( + input: SetCustomUserAgentInput +): (() => void) => { + // Save custom user-agent state & increment reference counter + // TODO Remove `any` when we upgrade to TypeScript 5.2, see: https://github.com/microsoft/TypeScript/issues/44373 + customUserAgentState[input.category] = (input.apis as any[]).reduce( + (acc: CategoryUserAgentStateMap, api: string) => ({ + ...acc, + [api]: { + refCount: acc[api]?.refCount ? acc[api].refCount + 1 : 1, + additionalDetails: input.additionalDetails, + }, + }), + customUserAgentState[input.category] ?? {} + ); + + // Callback that cleans up state for APIs recorded by this call + let cleanUpCallbackCalled = false; + const cleanUpCallback = () => { + // Only allow the cleanup callback to be called once + if (cleanUpCallbackCalled) { + return; + } + cleanUpCallbackCalled = true; + + input.apis.forEach(api => { + const apiRefCount = customUserAgentState[input.category][api].refCount; + + if (apiRefCount > 1) { + customUserAgentState[input.category][api].refCount = apiRefCount - 1; + } else { + delete customUserAgentState[input.category][api]; + + // Clean up category if no more APIs set + if (!Object.keys(customUserAgentState[input.category]).length) { + delete customUserAgentState[input.category]; + } + } + }); + }; + + return cleanUpCallback; +}; + +export const getCustomUserAgent = ( + category: string, + api: string +): AdditionalDetails | undefined => + customUserAgentState[category]?.[api]?.additionalDetails; diff --git a/packages/core/src/Platform/index.ts b/packages/core/src/Platform/index.ts index e9e3ef704be..ec69df9c505 100644 --- a/packages/core/src/Platform/index.ts +++ b/packages/core/src/Platform/index.ts @@ -5,6 +5,7 @@ import { CustomUserAgentDetails, Framework } from './types'; import { version } from './version'; import { detectFramework, observeFrameworkChanges } from './detectFramework'; import { UserAgent as AWSUserAgent } from '@aws-sdk/types'; +import { getCustomUserAgent } from './customUserAgent'; const BASE_USER_AGENT = `aws-amplify`; @@ -39,6 +40,16 @@ export const getAmplifyUserAgentObject = ({ } userAgent.push(['framework', detectFramework()]); + if (category && action) { + const customState = getCustomUserAgent(category, action); + + if (customState) { + customState.forEach(state => { + userAgent.push(state); + }); + } + } + return userAgent; }; @@ -47,7 +58,9 @@ export const getAmplifyUserAgent = ( ): string => { const userAgent = getAmplifyUserAgentObject(customUserAgentDetails); const userAgentString = userAgent - .map(([agentKey, agentValue]) => `${agentKey}/${agentValue}`) + .map(([agentKey, agentValue]) => + agentKey && agentValue ? `${agentKey}/${agentValue}` : agentKey + ) .join(' '); return userAgentString; diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index fe2b293bad0..003c9018838 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -110,7 +110,7 @@ export enum StorageAction { Copy = '4', Remove = '5', GetProperties = '6', - GetUrl = '7' + GetUrl = '7', } type ActionMap = { @@ -150,3 +150,46 @@ export type CustomUserAgentDetails = | UserAgentDetailsWithCategory | UserAgentDetailsWithCategory | UserAgentDetailsWithCategory; + +/** + * `refCount` tracks how many consumers have set state for a particular API to avoid it being cleared before all + * consumers are done using it. + * + * Category -> Action -> Custom State + */ +export type CategoryUserAgentStateMap = Record< + string, + { refCount: number; additionalDetails: AdditionalDetails } +>; +export type CustomUserAgentStateMap = Record; + +export type AdditionalDetails = [string, string?][]; + +type StorageUserAgentInput = { + category: Category.Storage; + apis: StorageAction[]; +}; + +type AuthUserAgentInput = { + category: Category.Auth; + apis: AuthAction[]; +}; + +type InAppMessagingUserAgentInput = { + category: Category.InAppMessaging; + apis: InAppMessagingAction[]; +}; + +type GeoUserAgentInput = { + category: Category.Geo; + apis: GeoAction[]; +}; + +export type SetCustomUserAgentInput = ( + | StorageUserAgentInput + | AuthUserAgentInput + | InAppMessagingUserAgentInput + | GeoUserAgentInput +) & { + additionalDetails: AdditionalDetails; +}; diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index 21a8f867782..a332e037b02 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -43,7 +43,7 @@ export { // Logging utilities export { ConsoleLogger, ConsoleLogger as Logger } from './Logger'; -// Platform & device utils +// Platform & user-agent utilities export { ClientDevice } from './ClientDevice'; export { Platform, @@ -65,7 +65,9 @@ export { PubSubAction, PushNotificationAction, StorageAction, + SetCustomUserAgentInput, } from './Platform/types'; +export { setCustomUserAgent } from './Platform/customUserAgent'; // Service worker export { ServiceWorker } from './ServiceWorker';