diff --git a/jest.setup.js b/jest.setup.js index 05fbee97db1..a0d54a2b09f 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,13 +1,13 @@ // Suppress console messages printing during unit tests. // Comment out log level as necessary (e.g. while debugging tests) -global.console = { - ...console, - log: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; +// global.console = { +// ...console, +// log: jest.fn(), +// debug: jest.fn(), +// info: jest.fn(), +// warn: jest.fn(), +// error: jest.fn(), +// }; // React Native global global['__DEV__'] = true; diff --git a/package.json b/package.json index b50ecce61d2..0f6c401c4b0 100644 --- a/package.json +++ b/package.json @@ -141,5 +141,6 @@ }, "overrides": { "tar": "6.2.1" - } + }, + "packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" } diff --git a/packages/adapter-nextjs/src/api/generateServerClient.ts b/packages/adapter-nextjs/src/api/generateServerClient.ts index e1c5ab09816..fe44db35cda 100644 --- a/packages/adapter-nextjs/src/api/generateServerClient.ts +++ b/packages/adapter-nextjs/src/api/generateServerClient.ts @@ -8,30 +8,34 @@ import { getAmplifyServerContext, } from '@aws-amplify/core/internals/adapter-core'; import { + CommonPublicClientOptions, V6ClientSSRCookies, V6ClientSSRRequest, } from '@aws-amplify/api-graphql'; -import { - GraphQLAuthMode, - parseAmplifyConfig, -} from '@aws-amplify/core/internals/utils'; +import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; import { NextServer } from '../types'; import { createServerRunnerForAPI } from './createServerRunnerForAPI'; -interface CookiesClientParams { +type CookiesClientParams< + WithEndpoint extends boolean, + WithApiKey extends boolean, +> = { cookies: NextServer.ServerComponentContext['cookies']; config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; -} +} & CommonPublicClientOptions; -interface ReqClientParams { +type ReqClientParams< + WithEndpoint extends boolean, + WithApiKey extends boolean, +> = { config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; -} +} & CommonPublicClientOptions; + +// NOTE: The type narrowing on CommonPublicClientOptions seems to hinge on +// defining these signatures separately. Not sure why offhand. This is worth +// some investigation later. /** * Generates an API client that can be used inside a Next.js Server Component with Dynamic Rendering @@ -44,13 +48,30 @@ interface ReqClientParams { */ export function generateServerClientUsingCookies< T extends Record = never, ->({ - config, - cookies, - authMode, - authToken, -}: CookiesClientParams): V6ClientSSRCookies { - if (typeof cookies !== 'function') { +>( + options: CookiesClientParams, +): V6ClientSSRCookies; +export function generateServerClientUsingCookies< + T extends Record = never, +>( + options: CookiesClientParams, +): V6ClientSSRCookies; +export function generateServerClientUsingCookies< + T extends Record = never, +>( + options: CookiesClientParams, +): V6ClientSSRCookies; +export function generateServerClientUsingCookies< + T extends Record = never, +>(options: CookiesClientParams): V6ClientSSRCookies; +export function generateServerClientUsingCookies< + T extends Record = never, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +>( + options: CookiesClientParams, +): 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 +82,28 @@ 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< + T, + V6ClientSSRCookies + >({ amplify: getAmplify, config: resourcesConfig, - authMode, - authToken, - }); + ...params, + } as any); // TS can't narrow the type here. } /** @@ -99,12 +124,29 @@ export function generateServerClientUsingCookies< */ export function generateServerClientUsingReqRes< T extends Record = never, ->({ config, authMode, authToken }: ReqClientParams): V6ClientSSRRequest { - const amplifyConfig = parseAmplifyConfig(config); +>(options: ReqClientParams): V6ClientSSRRequest; +export function generateServerClientUsingReqRes< + T extends Record = never, +>(options: ReqClientParams): V6ClientSSRRequest; +export function generateServerClientUsingReqRes< + T extends Record = never, +>(options: ReqClientParams): V6ClientSSRRequest; +export function generateServerClientUsingReqRes< + T extends Record = never, +>(options: ReqClientParams): V6ClientSSRRequest; +export function generateServerClientUsingReqRes< + T extends Record = never, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +>( + options: ReqClientParams, +): V6ClientSSRRequest { + const amplifyConfig = parseAmplifyConfig(options.config); + + const { config: _config, ...params } = options; - return generateClient({ + return generateClient({ config: amplifyConfig, - authMode, - authToken, - }); + ...params, + } as any); // TS can't narrow the type here. } diff --git a/packages/api-graphql/__tests__/GraphQLAPI.test.ts b/packages/api-graphql/__tests__/GraphQLAPI.test.ts index 8f2e953d906..75a06619e73 100644 --- a/packages/api-graphql/__tests__/GraphQLAPI.test.ts +++ b/packages/api-graphql/__tests__/GraphQLAPI.test.ts @@ -1614,6 +1614,7 @@ describe('API test', () => { const subscribeOptions = spyon_appsync_realtime.mock.calls[0][0]; expect(subscribeOptions).toBe(resolvedUrl); }); + test('graphql method handles INTERNAL_USER_AGENT_OVERRIDE correctly', async () => { Amplify.configure({ API: { diff --git a/packages/api-graphql/__tests__/internals/server/generateClientWithAmplifyInstance.test.ts b/packages/api-graphql/__tests__/internals/server/generateClientWithAmplifyInstance.test.ts index 8af92fad586..704881899a2 100644 --- a/packages/api-graphql/__tests__/internals/server/generateClientWithAmplifyInstance.test.ts +++ b/packages/api-graphql/__tests__/internals/server/generateClientWithAmplifyInstance.test.ts @@ -36,10 +36,7 @@ describe('server generateClient', () => { test('subscriptions are disabled', () => { const getAmplify = async (fn: any) => await fn(Amplify); - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRCookies - >({ + const client = generateClientWithAmplifyInstance({ amplify: getAmplify, config: config, }); @@ -71,10 +68,7 @@ describe('server generateClient', () => { const getAmplify = async (fn: any) => await fn(Amplify); - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRCookies - >({ + const client = generateClientWithAmplifyInstance({ amplify: getAmplify, config: config, }); @@ -118,10 +112,7 @@ describe('server generateClient', () => { const getAmplify = async (fn: any) => await fn(Amplify); - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRCookies - >({ + const client = generateClientWithAmplifyInstance({ amplify: getAmplify, config: config, }); @@ -167,10 +158,7 @@ describe('server generateClient', () => { const getAmplify = async (fn: any) => await fn(Amplify); - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRCookies - >({ + const client = generateClientWithAmplifyInstance({ amplify: getAmplify, config: config, }); @@ -197,10 +185,7 @@ describe('server generateClient', () => { describe('with request', () => { test('subscriptions are disabled', () => { - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRRequest - >({ + const client = generateClientWithAmplifyInstance({ amplify: null, config: config, }); @@ -215,10 +200,7 @@ describe('server generateClient', () => { Amplify.configure(configFixture as any); const config = Amplify.getConfig(); - const client = generateClientWithAmplifyInstance< - Schema, - V6ClientSSRRequest - >({ + const client = generateClientWithAmplifyInstance({ amplify: null, config: config, }); diff --git a/packages/api-graphql/__tests__/utils/expects.ts b/packages/api-graphql/__tests__/utils/expects.ts index e0085cc4980..53c228d8d4f 100644 --- a/packages/api-graphql/__tests__/utils/expects.ts +++ b/packages/api-graphql/__tests__/utils/expects.ts @@ -2,6 +2,18 @@ import { parse, print, DocumentNode } from 'graphql'; import { CustomHeaders } from '@aws-amplify/data-schema-types'; import { Amplify } from 'aws-amplify'; +type SpecialRequestVariations = { + /** + * A string or jest matcher. + */ + endpoint?: any; + + /** + * Object or jest matcher. + */ + headers?: any; +}; + /** * Performs an `expect()` on a jest spy with some basic nested argument checks * based on the given mutation `opName` and `item`. @@ -14,12 +26,16 @@ export function expectMutation( spy: jest.SpyInstance, opName: string, item: Record, + { + endpoint = 'https://localhost/graphql', + headers = expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + }: SpecialRequestVariations = {}, ) { expect(spy).toHaveBeenCalledWith({ abortController: expect.any(AbortController), - url: new URL('https://localhost/graphql'), + url: new URL(endpoint), options: expect.objectContaining({ - headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + headers, body: expect.objectContaining({ query: expect.stringContaining( `${opName}(input: $input, condition: $condition)`, @@ -44,6 +60,10 @@ export function expectGet( spy: jest.SpyInstance, opName: string, item: Record, + { + endpoint = 'https://localhost/graphql', + headers = expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + }: SpecialRequestVariations = {}, ) { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -53,9 +73,9 @@ export function expectGet( }), { abortController: expect.any(AbortController), - url: new URL('https://localhost/graphql'), + url: new URL(endpoint), options: expect.objectContaining({ - headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + headers, body: expect.objectContaining({ query: expect.stringContaining(`${opName}(id: $id)`), variables: expect.objectContaining(item), @@ -77,12 +97,16 @@ export function expectList( spy: jest.SpyInstance, opName: string, item: Record, + { + endpoint = 'https://localhost/graphql', + headers = expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + }: SpecialRequestVariations = {}, ) { expect(spy).toHaveBeenCalledWith({ abortController: expect.any(AbortController), - url: new URL('https://localhost/graphql'), + url: new URL(endpoint), options: expect.objectContaining({ - headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + headers, body: expect.objectContaining({ query: expect.stringContaining( `${opName}(filter: $filter, limit: $limit, nextToken: $nextToken)`, @@ -105,12 +129,20 @@ export function expectSub( spy: jest.SpyInstance, opName: string, item: Record, + { + endpoint = 'https://localhost/graphql', + authenticationType = 'apiKey', + apiKey = 'FAKE-KEY', + }: SpecialRequestVariations & { + authenticationType?: string; + apiKey?: string; + } = {}, ) { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - authenticationType: 'apiKey', - apiKey: 'FAKE-KEY', - appSyncGraphqlEndpoint: 'https://localhost/graphql', + authenticationType, + apiKey, + appSyncGraphqlEndpoint: endpoint, // Code-gen'd queries have an owner param; TypeBeast queries don't: query: expect.stringContaining(`${opName}(filter: $filter`), variables: expect.objectContaining(item), @@ -136,13 +168,21 @@ export function expectSubWithHeaders( spy: jest.SpyInstance, opName: string, item: Record, - headers?: CustomHeaders, + { + endpoint = 'https://localhost/graphql', + authenticationType = 'apiKey', + apiKey = 'FAKE-KEY', + headers = {}, + }: SpecialRequestVariations & { + authenticationType?: string; + apiKey?: string; + } = {}, ) { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - authenticationType: 'apiKey', - apiKey: 'FAKE-KEY', - appSyncGraphqlEndpoint: 'https://localhost/graphql', + authenticationType, + apiKey, + appSyncGraphqlEndpoint: endpoint, // Code-gen'd queries have an owner param; TypeBeast queries don't: query: expect.stringContaining(`${opName}(filter: $filter`), variables: expect.objectContaining(item), @@ -168,12 +208,16 @@ export function expectSubWithHeadersFn( spy: jest.SpyInstance, opName: string, item: Record, + { + endpoint = 'https://localhost/graphql', + headers = expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), + }: SpecialRequestVariations = {}, ) { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ authenticationType: 'apiKey', apiKey: 'FAKE-KEY', - appSyncGraphqlEndpoint: 'https://localhost/graphql', + appSyncGraphqlEndpoint: endpoint, // Code-gen'd queries have an owner param; TypeBeast queries don't: query: expect.stringContaining(`${opName}(filter: $filter`), variables: expect.objectContaining(item), diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index f7d1a60d556..c7ba064b013 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -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; @@ -369,10 +389,10 @@ export class InternalGraphQLAPIClass { { query: print(query as DocumentNode), variables, - appSyncGraphqlEndpoint: config?.endpoint, + appSyncGraphqlEndpoint: endpoint ?? config?.endpoint, 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..e208a400f2a 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,19 @@ import { ClientGenerationParams } from './types'; * @param params * @returns */ -export function generateClient = never>( - params: ClientGenerationParams, -): V6Client { +export function generateClient< + T extends Record = never, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +>( + params: ClientGenerationParams, +): 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 +61,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/server/generateClientWithAmplifyInstance.ts b/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts index 8a9927d543c..8529e9f0f83 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'; @@ -31,15 +33,17 @@ import { cancel, graphql, isCancelError } from '..'; export function generateClientWithAmplifyInstance< T extends Record = never, ClientType extends - | V6ClientSSRRequest - | V6ClientSSRCookies = V6ClientSSRCookies, + | V6ClientSSRRequest + | V6ClientSSRCookies = V6ClientSSRCookies, >( - params: ServerClientGenerationParams & CommonPublicClientOptions, + params: ServerClientGenerationParams & CommonPublicClientOptions, ): ClientType { 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, @@ -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..3c31ad025f2 100644 --- a/packages/api-graphql/src/internals/types.ts +++ b/packages/api-graphql/src/internals/types.ts @@ -9,15 +9,46 @@ import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; * * The knobs available for configuring `generateClient` internally. */ -export type ClientGenerationParams = { +export type ClientGenerationParams< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, +> = { amplify: AmplifyClassV6; -} & CommonPublicClientOptions; +} & CommonPublicClientOptions; /** * Common options that can be used on public `generateClient()` interfaces. */ -export interface CommonPublicClientOptions { - authMode?: GraphQLAuthMode; - authToken?: string; - headers?: CustomHeaders; -} +export type CommonPublicClientOptions< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, +> = WithCustomEndpoint extends true + ? WithApiKey extends true + ? + | { + endpoint: string; + authMode: 'apiKey'; + apiKey: string; + authToken?: string; + headers?: CustomHeaders; + } + | { + endpoint: string; + apiKey: string; + authMode: Exclude; + authToken?: string; + headers?: CustomHeaders; + } + : { + endpoint: string; + authMode: Exclude; + apiKey?: never; + authToken?: string; + headers?: CustomHeaders; + } + : { + endpoint?: never; + authMode?: GraphQLAuthMode; + authToken?: string; + headers?: CustomHeaders; + }; diff --git a/packages/api-graphql/src/internals/v6.ts b/packages/api-graphql/src/internals/v6.ts index c5d362908c8..793b3c8bd28 100644 --- a/packages/api-graphql/src/internals/v6.ts +++ b/packages/api-graphql/src/internals/v6.ts @@ -104,8 +104,43 @@ export function graphql< ): 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; /** @@ -116,7 +151,10 @@ export function graphql< const result = GraphQLAPI.graphql( // TODO: move V6Client back into this package? internals.amplify as any, - options, + { + ...options, + endpoint: clientEndpoint, + }, headers, ); diff --git a/packages/api-graphql/src/server/generateClient.ts b/packages/api-graphql/src/server/generateClient.ts index 09a60595231..2340a591b2a 100644 --- a/packages/api-graphql/src/server/generateClient.ts +++ b/packages/api-graphql/src/server/generateClient.ts @@ -33,38 +33,47 @@ import { * }), * }); */ -export function generateClient = never>({ - config, - authMode, - authToken, -}: GenerateServerClientParams): V6ClientSSRRequest { +export function generateClient< + T extends Record = never, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +>( + options: GenerateServerClientParams, +): V6ClientSSRRequest { // passing `null` instance because each (future model) method must retrieve a valid instance // from server context - const client = generateClientWithAmplifyInstance>({ + const client = generateClientWithAmplifyInstance< + T, + V6ClientSSRRequest + >({ 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< + WithCustomEndpoint, + WithApiKey + >; 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< + WithCustomEndpoint, + WithApiKey + >; return client; } diff --git a/packages/api-graphql/src/types/index.ts b/packages/api-graphql/src/types/index.ts index 0ecac34369a..a2df9091285 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 { 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,69 @@ 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, -> { - query: TYPED_GQL_STRING | DocumentNode; - variables?: GraphQLVariablesV6; - authMode?: GraphQLAuthMode; - authToken?: string; - /** - * @deprecated This property should not be used - */ - userAgentSuffix?: string; -} + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, // i.e., The client already has apiKey configured. +> = WithCustomEndpoint extends true + ? WithApiKey extends true + ? { + 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 +423,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 +445,60 @@ export type ClientWithModels = | V6ClientSSRRequest | V6ClientSSRCookies; -export type V6Client = never> = { - graphql: GraphQLMethod; +export type V6Client< + T extends Record = never, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +> = { + 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, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +> = { + 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, + WithCustomEndpoint extends boolean = false, + WithApiKey extends boolean = false, +> = { + graphql: GraphQLMethod; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensionsSSRCookies; -export type GraphQLMethod = < - FALLBACK_TYPES = unknown, - TYPED_GQL_STRING extends string = string, ->( - options: GraphQLOptionsV6, +export type GraphQLMethod< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, +> = ( + options: GraphQLOptionsV6< + FALLBACK_TYPES, + TYPED_GQL_STRING, + WithCustomEndpoint, + WithApiKey + >, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; -export type GraphQLMethodSSR = < - FALLBACK_TYPES = unknown, - TYPED_GQL_STRING extends string = string, ->( +export type GraphQLMethodSSR< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, +> = ( contextSpec: AmplifyServer.ContextSpec, - options: GraphQLOptionsV6, + options: GraphQLOptionsV6< + FALLBACK_TYPES, + TYPED_GQL_STRING, + WithCustomEndpoint, + WithApiKey + >, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; @@ -450,8 +530,9 @@ export interface AuthModeParams extends Record { authToken?: string; } -export interface GenerateServerClientParams { +export type GenerateServerClientParams< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, +> = { 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..4ae7e18168c 100644 --- a/packages/api/__tests__/API.test.ts +++ b/packages/api/__tests__/API.test.ts @@ -1,81 +1,1134 @@ -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'; +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'; -const serverManagedFields = { - id: 'some-id', - owner: 'wirejobviously', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), -}; +// 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.spyOn((GraphQLAPI as any).appSyncRealTime, 'subscribe'); + +/** + * 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(), + ); +} + +/** + * 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() + }); -describe('API generateClient', () => { afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); - 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(); }); - test('CONNECTION_STATE_CHANGE importable as a value, not a type', async () => { - expect(CONNECTION_STATE_CHANGE).toBe('ConnectionStateChange'); + 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(); + }); + + 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 5fe2429ee6f..173406972b0 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..e8a13aadd44 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -4,6 +4,10 @@ import { CommonPublicClientOptions, V6Client } from '@aws-amplify/api-graphql'; import { generateClient as internalGenerateClient } from '@aws-amplify/api-graphql/internals'; import { Amplify } from '@aws-amplify/core'; +// NOTE: The type narrowing on CommonPublicClientOptions seems to hinge on +// defining these signatures separately. Not sure why offhand. This is worth +// some investigation later. + /** * Generates an API client that can work with models or raw GraphQL * @@ -11,10 +15,26 @@ import { Amplify } from '@aws-amplify/core'; * @throws {@link Error} - Throws error when client cannot be generated due to configuration issues. */ export function generateClient = never>( - options: CommonPublicClientOptions = {}, -): V6Client { + options?: CommonPublicClientOptions, +): V6Client; +export function generateClient = never>( + options?: CommonPublicClientOptions, +): V6Client; +export function generateClient = never>( + options?: CommonPublicClientOptions, +): V6Client; +export function generateClient = never>( + options?: CommonPublicClientOptions, +): V6Client; +export function generateClient< + WithCustomEndpoint extends boolean, + WithApiKey extends boolean, + T extends Record = never, +>( + options?: CommonPublicClientOptions, +): V6Client { return internalGenerateClient({ - ...options, + ...(options || ({} as any)), amplify: Amplify, - }) as unknown as V6Client; + }) as unknown as V6Client; }