diff --git a/.changeset/strong-toes-sniff.md b/.changeset/strong-toes-sniff.md new file mode 100644 index 0000000000..f20c42c831 --- /dev/null +++ b/.changeset/strong-toes-sniff.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/backend-data': minor +'@aws-amplify/backend': minor +--- + +Add GraphQL API ID and Amplify environment name to custom JS resolver stash diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index 708bec2391..86df264435 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -78,6 +78,7 @@ "hotswappable", "hotswapped", "hotswapping", + "href", "iamv2", "identitypool", "idps", diff --git a/packages/backend-data/src/assets/js_resolver_handler.ts b/packages/backend-data/src/assets/js_resolver_handler.ts index 282dfb725d..81b26c4e1b 100644 --- a/packages/backend-data/src/assets/js_resolver_handler.ts +++ b/packages/backend-data/src/assets/js_resolver_handler.ts @@ -1,7 +1,9 @@ /** * Pipeline resolver request handler */ -export const request = () => { +export const request = (ctx: Record>) => { + ctx.stash.awsAppsyncApiId = '${amplifyApiId}'; + ctx.stash.amplifyApiEnvironmentName = '${amplifyApiEnvironmentName}'; return {}; }; /** diff --git a/packages/backend-data/src/convert_js_resolvers.test.ts b/packages/backend-data/src/convert_js_resolvers.test.ts index 51a646de6f..19508de86a 100644 --- a/packages/backend-data/src/convert_js_resolvers.test.ts +++ b/packages/backend-data/src/convert_js_resolvers.test.ts @@ -1,4 +1,4 @@ -import { Template } from 'aws-cdk-lib/assertions'; +import { Match, Template } from 'aws-cdk-lib/assertions'; import assert from 'node:assert'; import { beforeEach, describe, it } from 'node:test'; import { App, Duration, Stack } from 'aws-cdk-lib'; @@ -6,10 +6,15 @@ import { AmplifyData, AmplifyDataDefinition, } from '@aws-amplify/data-construct'; -import { resolve } from 'path'; -import { fileURLToPath } from 'url'; -import { convertJsResolverDefinition } from './convert_js_resolvers.js'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { + convertJsResolverDefinition, + defaultJsResolverCode, +} from './convert_js_resolvers.js'; import { a } from '@aws-amplify/data-schema'; +import { writeFileSync } from 'node:fs'; // stub schema for the AmplifyApi construct // not relevant to this test suite @@ -28,6 +33,33 @@ const createStackAndSetContext = (): Stack => { return stack; }; +void describe('defaultJsResolverCode', () => { + void it('returns the default JS resolver code with api id and env name in valid JS', async () => { + const code = defaultJsResolverCode('testApiId', 'testEnvName'); + assert(code.includes("ctx.stash.awsAppsyncApiId = 'testApiId';")); + assert( + code.includes("ctx.stash.amplifyApiEnvironmentName = 'testEnvName';") + ); + + const tempDir = tmpdir(); + const filename = join(tempDir, 'js_resolver_handler.js'); + writeFileSync(filename, code); + + // windows requires dynamic imports to use file urls + const fileUrl = pathToFileURL(filename).href; + const resolver = await import(fileUrl); + const context = { stash: {}, prev: { result: 'result' } }; + assert.deepEqual(resolver.request(context), {}); + + // assert api id and env name are added to the context stash + assert.deepEqual(context.stash, { + awsAppsyncApiId: 'testApiId', + amplifyApiEnvironmentName: 'testEnvName', + }); + assert.equal(resolver.response(context), 'result'); + }); +}); + void describe('convertJsResolverDefinition', () => { let stack: Stack; let amplifyApi: AmplifyData; @@ -158,4 +190,52 @@ void describe('convertJsResolverDefinition', () => { template.resourceCountIs('AWS::AppSync::Resolver', 1); }); + + void it('adds api id and environment name to stash', () => { + const absolutePath = resolve( + fileURLToPath(import.meta.url), + '../../lib/assets', + 'js_resolver_handler.js' + ); + + const schema = a.schema({ + customQuery: a + .query() + .authorization((allow) => allow.publicApiKey()) + .returns(a.string()) + .handler( + a.handler.custom({ + entry: absolutePath, + }) + ), + }); + const { jsFunctions } = schema.transform(); + convertJsResolverDefinition(stack, amplifyApi, jsFunctions); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::AppSync::Resolver', { + Runtime: { + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + }, + Kind: 'PIPELINE', + TypeName: 'Query', + FieldName: 'customQuery', + Code: { + 'Fn::Join': [ + '', + [ + "/**\n * Pipeline resolver request handler\n */\nexport const request = (ctx) => {\n ctx.stash.awsAppsyncApiId = '", + { + 'Fn::GetAtt': [ + Match.stringLikeRegexp('amplifyDataGraphQLAPI.*'), + 'ApiId', + ], + }, + "';\n ctx.stash.amplifyApiEnvironmentName = 'NONE';\n return {};\n};\n/**\n * Pipeline resolver response handler\n */\nexport const response = (ctx) => {\n return ctx.prev.result;\n};\n", + ], + ], + }, + }); + }); }); diff --git a/packages/backend-data/src/convert_js_resolvers.ts b/packages/backend-data/src/convert_js_resolvers.ts index e4d23eec33..5117a1135e 100644 --- a/packages/backend-data/src/convert_js_resolvers.ts +++ b/packages/backend-data/src/convert_js_resolvers.ts @@ -4,6 +4,7 @@ import { CfnFunctionConfiguration, CfnResolver } from 'aws-cdk-lib/aws-appsync'; import { JsResolver } from '@aws-amplify/data-schema-types'; import { resolve } from 'path'; import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'fs'; import { Asset } from 'aws-cdk-lib/aws-s3-assets'; import { resolveEntryPath } from './resolve_entry_path.js'; @@ -18,17 +19,25 @@ const JS_PIPELINE_RESOLVER_HANDLER = './assets/js_resolver_handler.js'; * It's required for defining a pipeline resolver. The only purpose it serves is returning the output of the last function in the pipeline back to the client. * * Customer-provided handlers are added as a Functions list in `pipelineConfig.functions` + * + * Add Amplify API ID and environment name to the context stash for use in the customer-provided handlers. */ -const defaultJsResolverAsset = (scope: Construct): Asset => { +export const defaultJsResolverCode = ( + amplifyApiId: string, + amplifyApiEnvironmentName: string +): string => { const resolvedTemplatePath = resolve( fileURLToPath(import.meta.url), '../../lib', JS_PIPELINE_RESOLVER_HANDLER ); - return new Asset(scope, 'default_js_resolver_handler_asset', { - path: resolveEntryPath(resolvedTemplatePath), - }); + return readFileSync(resolvedTemplatePath, 'utf-8') + .replace(new RegExp(/\$\{amplifyApiId\}/, 'g'), amplifyApiId) + .replace( + new RegExp(/\$\{amplifyApiEnvironmentName\}/, 'g'), + amplifyApiEnvironmentName + ); }; /** @@ -44,8 +53,6 @@ export const convertJsResolverDefinition = ( return; } - const jsResolverTemplateAsset = defaultJsResolverAsset(scope); - for (const resolver of jsResolvers) { const functions: string[] = resolver.handlers.map((handler, idx) => { const fnName = `Fn_${resolver.typeName}_${resolver.fieldName}_${idx + 1}`; @@ -71,12 +78,15 @@ export const convertJsResolverDefinition = ( const resolverName = `Resolver_${resolver.typeName}_${resolver.fieldName}`; + const amplifyApiEnvironmentName = + scope.node.tryGetContext('amplifyEnvironmentName') ?? 'NONE'; new CfnResolver(scope, resolverName, { apiId: amplifyApi.apiId, fieldName: resolver.fieldName, typeName: resolver.typeName, kind: APPSYNC_PIPELINE_RESOLVER, - codeS3Location: jsResolverTemplateAsset.s3ObjectUrl, + // Uses synth-time inline code to avoid circular dependency when adding the API ID as an environment variable. + code: defaultJsResolverCode(amplifyApi.apiId, amplifyApiEnvironmentName), runtime: { name: APPSYNC_JS_RUNTIME_NAME, runtimeVersion: APPSYNC_JS_RUNTIME_VERSION,