diff --git a/packages/adapter-nextjs/src/api/generateServerClient.ts b/packages/adapter-nextjs/src/api/generateServerClient.ts index e1c5ab09816..14133891b94 100644 --- a/packages/adapter-nextjs/src/api/generateServerClient.ts +++ b/packages/adapter-nextjs/src/api/generateServerClient.ts @@ -12,9 +12,10 @@ import { V6ClientSSRRequest, } from '@aws-amplify/api-graphql'; import { - GraphQLAuthMode, - parseAmplifyConfig, -} from '@aws-amplify/core/internals/utils'; + CommonPublicClientOptions, + DefaultCommonClientOptions, +} from '@aws-amplify/api-graphql/dist/esm/internals/types'; +import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; import { NextServer } from '../types'; @@ -23,14 +24,10 @@ import { createServerRunnerForAPI } from './createServerRunnerForAPI'; interface CookiesClientParams { cookies: NextServer.ServerComponentContext['cookies']; config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; } interface ReqClientParams { config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; } /** @@ -44,13 +41,10 @@ interface ReqClientParams { */ export function generateServerClientUsingCookies< T extends Record = never, ->({ - config, - cookies, - authMode, - authToken, -}: CookiesClientParams): V6ClientSSRCookies { - if (typeof cookies !== 'function') { + Options extends CommonPublicClientOptions & + CookiesClientParams = DefaultCommonClientOptions & CookiesClientParams, +>(options: Options): V6ClientSSRCookies { + if (typeof options.cookies !== 'function') { throw new AmplifyServerContextError({ message: 'generateServerClientUsingCookies is only compatible with the `cookies` Dynamic Function available in Server Components.', @@ -61,24 +55,25 @@ export function generateServerClientUsingCookies< } const { runWithAmplifyServerContext, resourcesConfig } = - createServerRunnerForAPI({ config }); + createServerRunnerForAPI({ config: options.config }); // This function reference gets passed down to InternalGraphQLAPI.ts.graphql // where this._graphql is passed in as the `fn` argument // causing it to always get invoked inside `runWithAmplifyServerContext` const getAmplify = (fn: (amplify: any) => Promise) => runWithAmplifyServerContext({ - nextServerContext: { cookies }, + nextServerContext: { cookies: options.cookies }, operation: contextSpec => fn(getAmplifyServerContext(contextSpec).amplify), }); - return generateClientWithAmplifyInstance>({ + const { cookies: _cookies, config: _config, ...params } = options; + + return generateClientWithAmplifyInstance>({ amplify: getAmplify, config: resourcesConfig, - authMode, - authToken, - }); + ...params, + } as any); // TS can't narrow the type here. } /** @@ -99,12 +94,15 @@ export function generateServerClientUsingCookies< */ export function generateServerClientUsingReqRes< T extends Record = never, ->({ config, authMode, authToken }: ReqClientParams): V6ClientSSRRequest { - const amplifyConfig = parseAmplifyConfig(config); + Options extends CommonPublicClientOptions & + ReqClientParams = DefaultCommonClientOptions & ReqClientParams, +>(options: Options): V6ClientSSRRequest { + const amplifyConfig = parseAmplifyConfig(options.config); + + const { config: _config, ...params } = options; return generateClient({ config: amplifyConfig, - authMode, - authToken, - }); + ...params, + }) as any; } diff --git a/packages/api-graphql/__tests__/internals/generateClient.test.ts b/packages/api-graphql/__tests__/internals/generateClient.test.ts index 94ba7bcafe4..8708cae9991 100644 --- a/packages/api-graphql/__tests__/internals/generateClient.test.ts +++ b/packages/api-graphql/__tests__/internals/generateClient.test.ts @@ -270,7 +270,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; expect(normalizePostGraphqlCalls(spy)).toMatchSnapshot(); @@ -497,7 +504,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -531,7 +545,14 @@ describe('generateClient', () => { }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -561,7 +582,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -583,7 +611,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -711,7 +746,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index f7d1a60d556..a1d93bd6cd9 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -52,7 +52,7 @@ export class InternalGraphQLAPIClass { /** * @private */ - private appSyncRealTime = new AWSAppSyncRealTimeProvider(); + private appSyncRealTime = new Map(); private _api = { post, @@ -88,7 +88,14 @@ export class InternalGraphQLAPIClass { amplify: | AmplifyClassV6 | ((fn: (amplify: any) => Promise) => Promise), - { query: paramQuery, variables = {}, authMode, authToken }: GraphQLOptions, + { + query: paramQuery, + variables = {}, + authMode, + authToken, + endpoint, + apiKey, + }: GraphQLOptions, additionalHeaders?: CustomHeaders, customUserAgentDetails?: CustomUserAgentDetails, ): Observable> | Promise> { @@ -115,7 +122,7 @@ export class InternalGraphQLAPIClass { if (isAmplifyInstance(amplify)) { responsePromise = this._graphql( amplify, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, abortController, customUserAgentDetails, @@ -127,7 +134,7 @@ export class InternalGraphQLAPIClass { const wrapper = async (amplifyInstance: AmplifyClassV6) => { const result = await this._graphql( amplifyInstance, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, abortController, customUserAgentDetails, @@ -152,7 +159,7 @@ export class InternalGraphQLAPIClass { case 'subscription': return this._graphqlSubscribe( amplify as AmplifyClassV6, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, customUserAgentDetails, authToken, @@ -164,7 +171,13 @@ export class InternalGraphQLAPIClass { private async _graphql( amplify: AmplifyClassV6, - { query, variables, authMode: explicitAuthMode }: GraphQLOptions, + { + query, + variables, + authMode: authModeOverride, + endpoint: endpointOverride, + apiKey: apiKeyOverride, + }: GraphQLOptions, additionalHeaders: CustomHeaders = {}, abortController: AbortController, customUserAgentDetails?: CustomUserAgentDetails, @@ -179,7 +192,7 @@ export class InternalGraphQLAPIClass { defaultAuthMode, } = resolveConfig(amplify); - const initialAuthMode = explicitAuthMode || defaultAuthMode || 'iam'; + const initialAuthMode = authModeOverride || defaultAuthMode || 'iam'; // identityPool is an alias for iam. TODO: remove 'iam' in v7 const authMode = initialAuthMode === 'identityPool' ? 'iam' : initialAuthMode; @@ -205,7 +218,7 @@ export class InternalGraphQLAPIClass { const requestOptions: RequestOptions = { method: 'POST', url: new AmplifyUrl( - customEndpoint || appSyncGraphqlEndpoint || '', + endpointOverride || customEndpoint || appSyncGraphqlEndpoint || '', ).toString(), queryString: print(query as DocumentNode), }; @@ -226,7 +239,7 @@ export class InternalGraphQLAPIClass { const authHeaders = await headerBasedAuth( amplify, authMode, - apiKey, + apiKeyOverride ?? apiKey, additionalCustomHeaders, ); @@ -282,7 +295,8 @@ export class InternalGraphQLAPIClass { }; } - const endpoint = customEndpoint || appSyncGraphqlEndpoint; + const endpoint = + endpointOverride || customEndpoint || appSyncGraphqlEndpoint; if (!endpoint) { throw createGraphQLResultWithError(new GraphQLApiError(NO_ENDPOINT)); @@ -341,7 +355,13 @@ export class InternalGraphQLAPIClass { private _graphqlSubscribe( amplify: AmplifyClassV6, - { query, variables, authMode: explicitAuthMode }: GraphQLOptions, + { + query, + variables, + authMode: authModeOverride, + apiKey: apiKeyOverride, + endpoint, + }: GraphQLOptions, additionalHeaders: CustomHeaders = {}, customUserAgentDetails?: CustomUserAgentDetails, authToken?: string, @@ -349,7 +369,7 @@ export class InternalGraphQLAPIClass { const config = resolveConfig(amplify); const initialAuthMode = - explicitAuthMode || config?.defaultAuthMode || 'iam'; + authModeOverride || config?.defaultAuthMode || 'iam'; // identityPool is an alias for iam. TODO: remove 'iam' in v7 const authMode = initialAuthMode === 'identityPool' ? 'iam' : initialAuthMode; @@ -364,15 +384,26 @@ export class InternalGraphQLAPIClass { */ const { headers: libraryConfigHeaders } = resolveLibraryOptions(amplify); - return this.appSyncRealTime + const appSyncGraphqlEndpoint = endpoint ?? config?.endpoint; + + // TODO: This could probably be an exception. But, lots of tests rely on + // attempting to connect to nowhere. So, I'm treating as the opposite of + // a Chesterton's fence for now. (A fence I shouldn't build, because I don't + // know why somethings depends on its absence!) + const memoKey = appSyncGraphqlEndpoint ?? 'none'; + const realtimeProvider = + this.appSyncRealTime.get(memoKey) ?? new AWSAppSyncRealTimeProvider(); + this.appSyncRealTime.set(memoKey, realtimeProvider); + + return realtimeProvider .subscribe( { query: print(query as DocumentNode), variables, - appSyncGraphqlEndpoint: config?.endpoint, + appSyncGraphqlEndpoint, region: config?.region, authenticationType: authMode, - apiKey: config?.apiKey, + apiKey: apiKeyOverride ?? config?.apiKey, additionalHeaders, authToken, libraryConfigHeaders, diff --git a/packages/api-graphql/src/internals/generateClient.ts b/packages/api-graphql/src/internals/generateClient.ts index 2831753424b..82c0a37fac4 100644 --- a/packages/api-graphql/src/internals/generateClient.ts +++ b/packages/api-graphql/src/internals/generateClient.ts @@ -13,8 +13,10 @@ import { import { V6Client, __amplify, + __apiKey, __authMode, __authToken, + __endpoint, __headers, getInternals, } from '../types'; @@ -33,13 +35,16 @@ import { ClientGenerationParams } from './types'; * @param params * @returns */ -export function generateClient = never>( - params: ClientGenerationParams, -): V6Client { +export function generateClient< + T extends Record = never, + Options extends ClientGenerationParams = ClientGenerationParams, +>(params: Options): V6Client { const client = { [__amplify]: params.amplify, [__authMode]: params.authMode, [__authToken]: params.authToken, + [__apiKey]: 'apiKey' in params ? params.apiKey : undefined, + [__endpoint]: 'endpoint' in params ? params.endpoint : undefined, [__headers]: params.headers, graphql, cancel, @@ -53,22 +58,37 @@ export function generateClient = never>( const apiGraphqlConfig = params.amplify.getConfig().API?.GraphQL; - if (isApiGraphQLConfig(apiGraphqlConfig)) { - addSchemaToClient(client, apiGraphqlConfig, getInternals); - } else { - // This happens when the `Amplify.configure()` call gets evaluated after the `generateClient()` call. - // - // Cause: when the `generateClient()` and the `Amplify.configure()` calls are located in - // different source files, script bundlers may randomly arrange their orders in the production - // bundle. - // - // With the current implementation, the `client.models` instance created by `generateClient()` - // will be rebuilt on every `Amplify.configure()` call that's provided with a valid GraphQL - // provider configuration. - // - // TODO: revisit, and reverify this approach when enabling multiple clients for multi-endpoints - // configuration. - generateModelsPropertyOnAmplifyConfigure(client); + if (client[__endpoint]) { + if (!client[__authMode]) { + throw new Error( + 'generateClient() requires an explicit `authMode` when `endpoint` is provided.', + ); + } + if (client[__authMode] === 'apiKey' && !client[__apiKey]) { + throw new Error( + "generateClient() requires an explicit `apiKey` when `endpoint` is provided and `authMode = 'apiKey'`.", + ); + } + } + + if (!client[__endpoint]) { + if (isApiGraphQLConfig(apiGraphqlConfig)) { + addSchemaToClient(client, apiGraphqlConfig, getInternals); + } else { + // This happens when the `Amplify.configure()` call gets evaluated after the `generateClient()` call. + // + // Cause: when the `generateClient()` and the `Amplify.configure()` calls are located in + // different source files, script bundlers may randomly arrange their orders in the production + // bundle. + // + // With the current implementation, the `client.models` instance created by `generateClient()` + // will be rebuilt on every `Amplify.configure()` call that's provided with a valid GraphQL + // provider configuration. + // + // TODO: revisit, and reverify this approach when enabling multiple clients for multi-endpoints + // configuration. + generateModelsPropertyOnAmplifyConfigure(client); + } } return client as any; diff --git a/packages/api-graphql/src/internals/index.ts b/packages/api-graphql/src/internals/index.ts index b6422196706..cf48d0bed4f 100644 --- a/packages/api-graphql/src/internals/index.ts +++ b/packages/api-graphql/src/internals/index.ts @@ -7,3 +7,4 @@ export { export { graphql, cancel, isCancelError } from './v6'; export { generateClient } from './generateClient'; +export { CommonPublicClientOptions, DefaultCommonClientOptions } from './types'; diff --git a/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts b/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts index 8a9927d543c..eb3dc63effc 100644 --- a/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts +++ b/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts @@ -9,8 +9,10 @@ import { V6ClientSSRCookies, V6ClientSSRRequest, __amplify, + __apiKey, __authMode, __authToken, + __endpoint, __headers, getInternals, } from '../../types'; @@ -40,6 +42,8 @@ export function generateClientWithAmplifyInstance< [__amplify]: params.amplify, [__authMode]: params.authMode, [__authToken]: params.authToken, + [__apiKey]: 'apiKey' in params ? params.apiKey : undefined, + [__endpoint]: 'endpoint' in params ? params.endpoint : undefined, [__headers]: params.headers, graphql, cancel, @@ -48,7 +52,20 @@ export function generateClientWithAmplifyInstance< const apiGraphqlConfig = params.config?.API?.GraphQL; - if (isApiGraphQLConfig(apiGraphqlConfig)) { + if (client[__endpoint]) { + if (!client[__authMode]) { + throw new Error( + 'generateClient() requires an explicit `authMode` when `endpoint` is provided.', + ); + } + if (client[__authMode] === 'apiKey' && !client[__apiKey]) { + throw new Error( + "generateClient() requires an explicit `apiKey` when `endpoint` is provided and `authMode = 'apiKey'`.", + ); + } + } + + if (!client[__endpoint] && isApiGraphQLConfig(apiGraphqlConfig)) { addSchemaToClientWithInstance(client, params, getInternals); } diff --git a/packages/api-graphql/src/internals/types.ts b/packages/api-graphql/src/internals/types.ts index edb0ab2599f..c4983d4c85f 100644 --- a/packages/api-graphql/src/internals/types.ts +++ b/packages/api-graphql/src/internals/types.ts @@ -13,11 +13,30 @@ export type ClientGenerationParams = { amplify: AmplifyClassV6; } & CommonPublicClientOptions; -/** - * Common options that can be used on public `generateClient()` interfaces. - */ -export interface CommonPublicClientOptions { +export interface DefaultCommonClientOptions { + endpoint?: never; authMode?: GraphQLAuthMode; + apiKey?: string; authToken?: string; headers?: CustomHeaders; } + +/** + * Common options that can be used on public `generateClient()` interfaces. + */ +export type CommonPublicClientOptions = + | DefaultCommonClientOptions + | { + endpoint: string; + authMode: 'apiKey'; + apiKey: string; + authToken?: string; + headers?: CustomHeaders; + } + | { + endpoint: string; + authMode: Exclude; + apiKey?: string; + authToken?: string; + headers?: CustomHeaders; + }; diff --git a/packages/api-graphql/src/internals/v6.ts b/packages/api-graphql/src/internals/v6.ts index 553707be092..9e5e9b8f92c 100644 --- a/packages/api-graphql/src/internals/v6.ts +++ b/packages/api-graphql/src/internals/v6.ts @@ -4,7 +4,7 @@ import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; import { GraphQLAPI } from '../GraphQLAPI'; import { - GraphQLOptions, + CommonPublicClientOptions, GraphQLOptionsV6, GraphQLResponseV6, V6Client, @@ -98,15 +98,51 @@ import { export function graphql< FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, + Options extends CommonPublicClientOptions = object, >( this: V6Client, - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders, ): GraphQLResponseV6 { // inject client-level auth const internals = getInternals(this as any); - options.authMode = options.authMode || internals.authMode; + + /** + * The custom `endpoint` specific to the client + */ + const clientEndpoint: string = (internals as any).endpoint; + + /** + * The `authMode` specific to the client. + */ + const clientAuthMode = internals.authMode; + + /** + * The `apiKey` specific to the client. + */ + const clientApiKey = (internals as any).apiKey; + + /** + * The most specific `authMode` wins. Setting an `endpoint` value without also + * setting an `authMode` value is invalid. This helps to prevent customers apps + * from unexpectedly sending auth details to endpoints the auth details do not + * belong to. + * + * This is especially pronounced for `apiKey`. When both an `endpoint` *and* + * `authMode: 'apiKey'` are provided, an explicit `apiKey` override is required + * to prevent inadvertent sending of an API's `apiKey` to an endpoint is does + * not belong to. + */ + options.authMode = options.authMode || clientAuthMode; + options.apiKey = options.apiKey ?? clientApiKey; options.authToken = options.authToken || internals.authToken; + + if (clientEndpoint && options.authMode === 'apiKey' && !options.apiKey) { + throw new Error( + "graphql() requires an explicit `apiKey` for a custom `endpoint` when `authMode = 'apiKey'`.", + ); + } + const headers = additionalHeaders || internals.headers; /** @@ -114,11 +150,13 @@ export function graphql< * Neither of these can actually be validated at runtime. Hence, we don't perform * any validation or type-guarding here. */ - const result = GraphQLAPI.graphql( // TODO: move V6Client back into this package? internals.amplify as any, - options as GraphQLOptions, + { + ...options, + endpoint: clientEndpoint, + } as any, headers, ); diff --git a/packages/api-graphql/src/server/generateClient.ts b/packages/api-graphql/src/server/generateClient.ts index 09a60595231..8f0a612a812 100644 --- a/packages/api-graphql/src/server/generateClient.ts +++ b/packages/api-graphql/src/server/generateClient.ts @@ -5,6 +5,7 @@ import { AmplifyServer, getAmplifyServerContext, } from '@aws-amplify/core/internals/adapter-core'; +import { ResourcesConfig } from '@aws-amplify/core'; import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; import { generateClientWithAmplifyInstance } from '../internals/server'; @@ -33,38 +34,35 @@ import { * }), * }); */ -export function generateClient = never>({ - config, - authMode, - authToken, -}: GenerateServerClientParams): V6ClientSSRRequest { +export function generateClient< + T extends Record = never, + Options extends GenerateServerClientParams = { config: ResourcesConfig }, +>(options: Options): V6ClientSSRRequest { // passing `null` instance because each (future model) method must retrieve a valid instance // from server context const client = generateClientWithAmplifyInstance>({ amplify: null, - config, - authMode, - authToken, + ...options, }); // TODO: improve this and the next type - const prevGraphql = client.graphql as unknown as GraphQLMethod; + const prevGraphql = client.graphql as unknown as GraphQLMethod; const wrappedGraphql = ( contextSpec: AmplifyServer.ContextSpec, - options: GraphQLOptionsV6, + innerOptions: GraphQLOptionsV6, additionalHeaders?: CustomHeaders, ) => { const amplifyInstance = getAmplifyServerContext(contextSpec).amplify; return prevGraphql.call( { [__amplify]: amplifyInstance }, - options, + innerOptions as any, additionalHeaders as any, ); }; - client.graphql = wrappedGraphql as unknown as GraphQLMethodSSR; + client.graphql = wrappedGraphql as unknown as GraphQLMethodSSR; return client; } diff --git a/packages/api-graphql/src/types/index.ts b/packages/api-graphql/src/types/index.ts index d642fff3bd1..415bc6cf33c 100644 --- a/packages/api-graphql/src/types/index.ts +++ b/packages/api-graphql/src/types/index.ts @@ -18,22 +18,26 @@ import { } from '@aws-amplify/core/internals/utils'; import { AmplifyServer } from '@aws-amplify/core/internals/adapter-core'; +import { CommonPublicClientOptions } from '../internals/types'; + export { OperationTypeNode } from 'graphql'; export { CONTROL_MSG, ConnectionState } from './PubSub'; export { SelectionSet } from '@aws-amplify/data-schema/runtime'; -export { CommonPublicClientOptions } from '../internals/types'; +export type { CommonPublicClientOptions }; /** * Loose/Unknown options for raw GraphQLAPICategory `graphql()`. */ export interface GraphQLOptions { query: string | DocumentNode; + endpoint?: string; variables?: Record; authMode?: GraphQLAuthMode; authToken?: string; + apiKey?: string; /** * @deprecated This property should not be used */ @@ -209,19 +213,68 @@ export type GraphQLOperation = Source | string; * API V6 `graphql({options})` type that can leverage branded graphql `query` * objects and fallback types. */ -export interface GraphQLOptionsV6< +export type GraphQLOptionsV6< FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, -> extends Record { - query: TYPED_GQL_STRING | DocumentNode; - variables?: GraphQLVariablesV6; - authMode?: GraphQLAuthMode; - authToken?: string; - /** - * @deprecated This property should not be used - */ - userAgentSuffix?: string; -} + Options extends CommonPublicClientOptions = object, +> = Options['endpoint'] extends string + ? Options['apiKey'] extends string + ? { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: GraphQLAuthMode; + apiKey?: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + : + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: never; + apiKey?: never; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode: 'apiKey'; + apiKey: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode: Exclude; + apiKey?: never; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + : { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: GraphQLAuthMode; + apiKey?: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + }; /** * Result type for `graphql()` operations that don't include any specific @@ -369,15 +422,19 @@ export type GeneratedSubscription = string & { export const __amplify = Symbol('amplify'); export const __authMode = Symbol('authMode'); export const __authToken = Symbol('authToken'); +export const __apiKey = Symbol('apiKey'); export const __headers = Symbol('headers'); +export const __endpoint = Symbol('endpoint'); export function getInternals(client: BaseClient): ClientInternals { const c = client as any; return { amplify: c[__amplify], + apiKey: c[__apiKey], authMode: c[__authMode], authToken: c[__authToken], + endpoint: c[__endpoint], headers: c[__headers], } as any; } @@ -387,38 +444,47 @@ export type ClientWithModels = | V6ClientSSRRequest | V6ClientSSRCookies; -export type V6Client = never> = { - graphql: GraphQLMethod; +export type V6Client< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethod; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensions; -export type V6ClientSSRRequest = never> = { - graphql: GraphQLMethodSSR; +export type V6ClientSSRRequest< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethodSSR; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensionsSSRRequest; -export type V6ClientSSRCookies = never> = { - graphql: GraphQLMethod; +export type V6ClientSSRCookies< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethod; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensionsSSRCookies; -export type GraphQLMethod = < +export type GraphQLMethod = < FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, >( - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; -export type GraphQLMethodSSR = < +export type GraphQLMethodSSR = < FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, >( contextSpec: AmplifyServer.ContextSpec, - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; @@ -450,8 +516,6 @@ export interface AuthModeParams extends Record { authToken?: string; } -export interface GenerateServerClientParams { +export type GenerateServerClientParams = { config: ResourcesConfig; - authMode?: GraphQLAuthMode; - authToken?: string; -} +} & CommonPublicClientOptions; diff --git a/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts b/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts deleted file mode 100644 index e53f52b1f93..00000000000 --- a/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const cancel = jest.fn(() => true); diff --git a/packages/api/__tests__/API.test.ts b/packages/api/__tests__/API.test.ts index ab95bf9dcc9..21b0b77cec8 100644 --- a/packages/api/__tests__/API.test.ts +++ b/packages/api/__tests__/API.test.ts @@ -1,81 +1,1148 @@ -import { ResourcesConfig } from 'aws-amplify'; -import { InternalGraphQLAPIClass } from '@aws-amplify/api-graphql/internals'; +import { enableFetchMocks } from 'jest-fetch-mock'; +import { Amplify } from '@aws-amplify/core'; +import { GraphQLAPI } from '@aws-amplify/api-graphql'; import { generateClient, CONNECTION_STATE_CHANGE } from '@aws-amplify/api'; -import { AmplifyClassV6 } from '@aws-amplify/core'; -// import { runWithAmplifyServerContext } from 'aws-amplify/internals/adapter-core'; - -const serverManagedFields = { - id: 'some-id', - owner: 'wirejobviously', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), +import { generateServerClientUsingCookies, generateServerClientUsingReqRes } from '@aws-amplify/adapter-nextjs/api'; +import { generateClientWithAmplifyInstance } from '@aws-amplify/api/internals'; +import { Observable } from 'rxjs'; +import { decodeJWT } from '@aws-amplify/core'; + +// Make global `Request` available. (Necessary for using `adapter-nextjs` clients.) +enableFetchMocks(); + +type AuthMode = + | 'apiKey' + | 'oidc' + | 'userPool' + | 'iam' + | 'identityPool' + | 'lambda' + | 'none'; + +const DEFAULT_AUTH_MODE = 'apiKey'; +const DEFAULT_API_KEY = 'FAKE-KEY'; +const CUSTOM_API_KEY = 'CUSTOM-API-KEY'; + +const DEFAULT_ENDPOINT = 'https://a-default-appsync-endpoint.local/graphql'; +const CUSTOM_ENDPOINT = 'https://a-custom-appsync-endpoint.local/graphql'; + +/** + * Validly parsable JWT string. (Borrowed from Auth tests.) + */ +const DEFAULT_AUTH_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MTAyOTMxMzB9.YzDpgJsrB3z-ZU1XxMcXSQsMbgCzwH_e-_76rnfehh0'; + +const _postSpy = jest.spyOn((GraphQLAPI as any)._api, 'post'); +const _subspy = jest.fn(); + +/** + * Should be called on every subscription, ensuring that realtime provider instances + * are re-used for each distinct endpoint. + */ +const _setProviderSpy = jest.fn(); + +(GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: _subspy } + }, + set: _setProviderSpy }; -describe('API generateClient', () => { +/** + * Validates that a specific "post" occurred (against `_postSpy`). + * + * @param options + */ +function expectPost({ + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + endpoint: string; + authMode: AuthMode; + apiKeyOverride: string | undefined; + authTokenOverride: string | undefined; +}) { + // + // Grabbing the call and asserting on the object is significantly simpler for some + // of the is-unknown-or-absent types of assertions we need. + // + // It is also incidentally much simpler for most the other assertions too ... + // + const postOptions = _postSpy.mock.calls[0][1] as { + // just the things we care about + url: URL; + options: { + headers: Record; + }; + }; + + expect(postOptions.url.toString()).toEqual(endpoint); + + if (authMode === 'apiKey') { + expect(postOptions.options.headers['X-Api-Key']).toEqual( + apiKeyOverride ?? DEFAULT_API_KEY, + ); + } else { + expect(postOptions.options.headers['X-Api-Key']).toBeUndefined(); + } + + if (['oidc', 'userPool'].includes(authMode)) { + expect(postOptions.options.headers['Authorization']).toEqual( + authTokenOverride ?? DEFAULT_AUTH_TOKEN, + ); + } else { + expect(postOptions.options.headers['Authorization']).toBeUndefined(); + } +} + +/** + * Validates that a specific subscription occurred (against `_subSpy`). + * + * @param options + */ +function expectSubscription({ + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + endpoint: string; + authMode: AuthMode; + apiKeyOverride: string | undefined; + authTokenOverride: string | undefined; +}) { + // `authMode` is provided to appsync provider, which then determines how to + // handle auth internally. + expect(_subspy).toHaveBeenCalledWith( + expect.objectContaining({ + appSyncGraphqlEndpoint: endpoint, + authenticationType: authMode, + + // appsync provider only receive an authToken if it has been explicitly overridden. + authToken: authTokenOverride, + + // appsync provider already receive an apiKey. + // (but it should not send it unless authMode is apiKey.) + apiKey: apiKeyOverride ?? DEFAULT_API_KEY, + }), + expect.anything(), + ); + expect(_setProviderSpy).toHaveBeenCalledWith(endpoint, expect.anything()); +} + +/** + * Validates that a specific operation was submitted to the correct underlying + * execution mechanism (post or AppSyncRealtime). + * + * @param param0 + */ +function expectOp({ + op, + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + op: 'subscription' | 'query'; + endpoint: string; + authMode: AuthMode; + apiKeyOverride?: string | undefined; + authTokenOverride?: string | undefined; +}) { + const doExpect = op === 'subscription' ? expectSubscription : expectPost; + doExpect({ endpoint, authMode, apiKeyOverride, authTokenOverride }); // test pass ... umm ... +} + +function prepareMocks() { + Amplify.configure( + { + API: { + GraphQL: { + defaultAuthMode: DEFAULT_AUTH_MODE, + apiKey: DEFAULT_API_KEY, + endpoint: DEFAULT_ENDPOINT, + region: 'north-pole-7', + }, + }, + Auth: { + Cognito: { + userPoolId: 'north-pole-7:santas-little-helpers', + identityPoolId: 'north-pole-7:santas-average-sized-helpers', + userPoolClientId: 'the-mrs-claus-oversight-committee', + }, + }, + }, + { + Auth: { + credentialsProvider: { + getCredentialsAndIdentityId: async arg => ({ + credentials: { + accessKeyId: 'accessKeyIdValue', + secretAccessKey: 'secretAccessKeyValue', + sessionToken: 'sessionTokenValue', + expiration: new Date(123), + }, + identityId: 'mrs-clause-naturally', + }), + clearCredentialsAndIdentityId: async () => {}, + }, + tokenProvider: { + getTokens: async () => ({ + accessToken: decodeJWT(DEFAULT_AUTH_TOKEN), + }), + }, + }, + }, + ); + _postSpy.mockReturnValue({ + body: { + json() { + return JSON.stringify({ + data: { + someOperation: { + someField: 'some value', + }, + }, + }); + }, + }, + }); + _subspy.mockReturnValue(new Observable()); +} + +describe('generateClient (web)', () => { + beforeEach(() => { + prepareMocks() + }); + afterEach(() => { jest.clearAllMocks(); }); - test('client-side client.graphql', async () => { - jest.spyOn(AmplifyClassV6.prototype, 'getConfig').mockImplementation(() => { - return { - API: { GraphQL: { endpoint: 'test', defaultAuthMode: 'none' } }, - }; + for (const op of ['query', 'subscription'] as const) { + const opType = op === 'subscription' ? 'sub' : 'qry'; + + describe(`[${opType}] without a custom endpoint`, () => { + test("does not require `authMode` or `apiKey` override", () => { + expect(() => { generateClient() }).not.toThrow(); + }); + + test("does not require `authMode` or `apiKey` override in client.graphql()", async () => { + const client = generateClient(); + + await client.graphql({ query: `${op} A { queryA { a b c } }` }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: DEFAULT_AUTH_MODE, + }); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClient({ + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode` override in `client.graphql()`", async () => { + const client = generateClient(); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool', + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `apiKey` override in `client.graphql()`", async () => { + const client = generateClient(); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", async () => { + const client = generateClient({ + authMode: 'userPool' + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); }); - const spy = jest - .spyOn(InternalGraphQLAPIClass.prototype, 'graphql') - .mockResolvedValue('grapqhqlResponse' as any); - const client = generateClient(); - expect(await client.graphql({ query: 'query' })).toBe('grapqhqlResponse'); - expect(spy).toHaveBeenCalledWith( - { Auth: {}, libraryOptions: {}, resourcesConfig: {} }, - { query: 'query' }, - undefined, - { - action: '1', - category: 'api', - }, - ); + + describe(`[${opType}] with a custom endpoint`, () => { + test("requires `authMode` override", () => { + // @ts-expect-error + expect(() => generateClient({ + endpoint: CUSTOM_ENDPOINT + })).toThrow() + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", async () => { + expect(() => { + generateClient({ + endpoint: CUSTOM_ENDPOINT, + // @ts-expect-error + authMode: 'apiKey', + }) + }).toThrow(); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode: 'none'` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool' + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + expect(() => client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey' + })).toThrow() + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }) + }; +}); + +describe('generateClient (cookie client)', () => { + + /** + * NOTICE + * + * Cookie client is largely a pass-thru to `generateClientWithAmplifyInstance`. + * + * These tests intend to cover narrowing rules on the public surface. Behavior is + * tested in the `SSR common` describe block. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + describe('typings', () => { + /** + * Static / Type tests only. + * + * (No executed intended or expected.) + */ + + describe('without a custom endpoint', () => { + test("do not require `authMode` or `apiKey` override", () => { + // expect no type error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies + }); + }); + + test("do not require `authMode` or `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies + }); + await client.graphql({ query: `query A { queryA { a b c } }` }); + } + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + authMode: 'userPool', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allow `authMode` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'userPool', + }); + } + }); + + test("allows `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + } + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + authMode: 'userPool' + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + } + }); + }) + + describe('with a custom endpoint', () => { + test("requires `authMode` override", () => { + // @ts-expect-error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT + }); + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", () => { + // @ts-expect-error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + }); + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'none'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'userPool' + }); + } + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey' + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + } + }); + }) + + }); +}); + +describe('generateClient (req/res client)', () => { + + /** + * NOTICE + * + * ReqRes client is largely a pass-thru to `server/generateClient`, which is a pass-thru + * to `generateClientWithAmplifyInstance` (with add Amplify instance). + * + * These tests intend to cover narrowing rules on the public surface. Behavior is + * tested in the `SSR common` describe block. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); }); - test('CONNECTION_STATE_CHANGE importable as a value, not a type', async () => { - expect(CONNECTION_STATE_CHANGE).toBe('ConnectionStateChange'); + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + const contextSpec = {} as any; + + describe('typings', () => { + /** + * Static / Type tests only. + * + * (No executed intended or expected.) + */ + + describe('without a custom endpoint', () => { + test("do not require `authMode` or `apiKey` override", () => { + // expect no type error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + }); + + test("do not require `authMode` or `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + await client.graphql(contextSpec, { query: `query A { queryA { a b c } }` }); + } + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + authMode: 'userPool', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allow `authMode` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'userPool', + }); + } + }); + + test("allows `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + } + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + authMode: 'userPool' + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + } + }); + }) + + describe('with a custom endpoint', () => { + test("requires `authMode` override", () => { + // @ts-expect-error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT + }); + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", () => { + // @ts-expect-error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + }); + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'none'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'userPool' + }); + } + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey' + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + } + }); + }) + }); - // test('server-side client.graphql', async () => { - // const config: ResourcesConfig = { - // API: { - // GraphQL: { - // apiKey: 'adsf', - // customEndpoint: undefined, - // customEndpointRegion: undefined, - // defaultAuthMode: 'apiKey', - // endpoint: 'https://0.0.0.0/graphql', - // region: 'us-east-1', - // }, - // }, - // }; - - // const query = `query Q { - // getWidget { - // __typename id owner createdAt updatedAt someField - // } - // }`; - - // const spy = jest - // .spyOn(InternalGraphQLAPIClass.prototype, 'graphql') - // .mockResolvedValue('grapqhqlResponse' as any); - - // await runWithAmplifyServerContext(config, {}, ctx => { - // const client = generateClientSSR(ctx); - // return client.graphql({ query }) as any; - // }); - - // expect(spy).toHaveBeenCalledWith( - // expect.objectContaining({ - // resourcesConfig: config, - // }), - // { query }, - // undefined - // ); - // }); }); + +describe('SSR common', () => { + /** + * NOTICE + * + * This tests the runtime validation behavior common to both SSR clients. + * + * 1. Cookie client uses `generateClientWithAmplifyInstance` directly. + * 2. ReqRest client uses `server/generateClient`. + * 3. `server/generateClient` is a pass-thru to `generateClientWithAmplifyInstance` that + * injects an `Amplify` instance. + * + * The runtime validations we need to check funnel through `generateClientWithAmplifyInstance`. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + for (const op of ['query', 'subscription'] as const) { + const opType = op === 'subscription' ? 'sub' : 'qry'; + + describe(`[${opType}] without a custom endpoint`, () => { + test("does not require `authMode` or `apiKey` override", () => { + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + })).not.toThrow(); + }); + + test("does not require `authMode` or `apiKey` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ query: `${op} A { queryA { a b c } }` }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: DEFAULT_AUTH_MODE, + }); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool', + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `apiKey` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + authMode: 'userPool' + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }); + + describe(`[${opType}] with a custom endpoint`, () => { + test("requires `authMode` override", () => { + // @ts-expect-error + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT + })).toThrow() + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", async () => { + // @ts-expect-error + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + })).toThrow(); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode: 'none'` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: {}, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool' + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", async () => { + // no TS expect error here. types for `generateClientWithAmplifyInstance` have been simplified + // because they are not customer-facing. + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + expect(() => client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey' + })).toThrow() + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }) + }; +}) diff --git a/packages/api/__tests__/SRR.test.ts b/packages/api/__tests__/SRR.test.ts new file mode 100644 index 00000000000..5b1b79d8dff --- /dev/null +++ b/packages/api/__tests__/SRR.test.ts @@ -0,0 +1,106 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; +import { Amplify, ResourcesConfig } from 'aws-amplify'; + +// allows SSR function to be invoked without catastrophically failing out of the gate. +enableFetchMocks(); + +const generateClientWithAmplifyInstanceSpy = jest.fn(); +jest.mock('@aws-amplify/api/internals', () => ({ + generateClientWithAmplifyInstance: generateClientWithAmplifyInstanceSpy +})); + +const generateClientSpy = jest.fn(); +jest.mock('aws-amplify/api/server', () => ({ + generateClient: generateClientSpy +})); + +const { + generateServerClientUsingCookies, + generateServerClientUsingReqRes, +} = require('@aws-amplify/adapter-nextjs/api'); + +describe('SSR internals', () => { + beforeEach(() => { + Amplify.configure( + { + API: { + GraphQL: { + defaultAuthMode: 'apiKey', + apiKey: 'a-key', + endpoint: 'https://an-endpoint.local/graphql', + region: 'north-pole-7', + }, + }, + Auth: { + Cognito: { + userPoolId: 'north-pole-7:santas-little-helpers', + identityPoolId: 'north-pole-7:santas-average-sized-helpers', + userPoolClientId: 'the-mrs-claus-oversight-committee', + }, + }, + } + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + test('generateServerClientUsingCookies passes through to generateClientWithAmplifyInstance', () => { + generateClientWithAmplifyInstanceSpy.mockReturnValue('generateClientWithAmplifyInstance client'); + + const options = { + config: Amplify.getConfig(), + cookies: cookies, // must be a function to internal sanity checks + authMode: "authMode value", + authToken: "authToken value", + apiKey: "apiKey value", + endpoint: "endpoint value", + headers: "headers value" + } as any; + + const { + config: _config, // config is replaced with resources config + cookies: _cookies, // cookies are not sent + ...params + } = options; + + const client = generateServerClientUsingCookies(options); + + expect(generateClientWithAmplifyInstanceSpy).toHaveBeenCalledWith( + expect.objectContaining(params) + ); + expect(client).toEqual('generateClientWithAmplifyInstance client'); + }); + + test('generateServerClientUsingReqRes passes through to generateClientSpy', () => { + generateClientSpy.mockReturnValue('generateClientSpy client'); + + const options = { + config: Amplify.getConfig(), + authMode: "authMode value", + authToken: "authToken value", + apiKey: "apiKey value", + endpoint: "endpoint value", + headers: "headers value" + } as any; + + const { + config: _config, // config is replaced with resources config + ...params + } = options; + + const client = generateServerClientUsingReqRes(options); + + expect(generateClientSpy).toHaveBeenCalledWith( + expect.objectContaining(params) + ); + expect(client).toEqual('generateClientSpy client'); + }); +}) \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 54168841f71..60d39083b87 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -68,7 +68,8 @@ }, "homepage": "https://aws-amplify.github.io/", "devDependencies": { - "typescript": "5.0.2" + "typescript": "5.0.2", + "jest-fetch-mock": "3.0.3" }, "files": [ "dist/cjs", diff --git a/packages/api/src/API.ts b/packages/api/src/API.ts index 8aee0fc3334..292c0baf6bc 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -1,7 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CommonPublicClientOptions, V6Client } from '@aws-amplify/api-graphql'; -import { generateClient as internalGenerateClient } from '@aws-amplify/api-graphql/internals'; +import { V6Client } from '@aws-amplify/api-graphql'; +import { + CommonPublicClientOptions, + DefaultCommonClientOptions, + generateClient as internalGenerateClient, +} from '@aws-amplify/api-graphql/internals'; import { Amplify } from '@aws-amplify/core'; /** @@ -10,11 +14,12 @@ import { Amplify } from '@aws-amplify/core'; * @returns {@link V6Client} * @throws {@link Error} - Throws error when client cannot be generated due to configuration issues. */ -export function generateClient = never>( - options: CommonPublicClientOptions = {}, -): V6Client { +export function generateClient< + T extends Record = never, + Options extends CommonPublicClientOptions = DefaultCommonClientOptions, +>(options?: Options): V6Client { return internalGenerateClient({ - ...options, + ...(options || ({} as any)), amplify: Amplify, - }) as unknown as V6Client; + }) as unknown as V6Client; } diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index cadaa7d905c..1e0f46e2a9a 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -335,7 +335,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "44.23 kB" + "limit": "45.47 kB" }, { "name": "[API] REST API handlers",