diff --git a/.changeset/brave-cheetahs-repeat.md b/.changeset/brave-cheetahs-repeat.md new file mode 100644 index 0000000000..3c0065e1ba --- /dev/null +++ b/.changeset/brave-cheetahs-repeat.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/backend': patch +'@aws-amplify/backend-function': patch +--- + +feat: Add backend-function runtime behavior to get the data config diff --git a/.changeset/rare-kiwis-live.md b/.changeset/rare-kiwis-live.md new file mode 100644 index 0000000000..bb1a87462a --- /dev/null +++ b/.changeset/rare-kiwis-live.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/backend': patch +'@aws-amplify/backend-data': patch +--- + +feat: Add mis build to S3 from the data construct factory diff --git a/package-lock.json b/package-lock.json index 1ce770cf49..d2d31d6636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2838,9 +2838,9 @@ } }, "node_modules/@aws-amplify/data-schema": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema/-/data-schema-1.5.1.tgz", - "integrity": "sha512-hFDqqwHqdoFazmvGOApCX8kqrdoum9YJikmAQN5tP2sgnCT++lqznFw2F4PPqDJRxhQP1AYuwhbbRBvGLMbs/w==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema/-/data-schema-1.13.4.tgz", + "integrity": "sha512-vtwcu8SSg2iGaDF/uJ6eGf8kiZSxFWmDhYJ5bJ4vkv91y4na1nZCphv40ehyRdAiMKVKmdLuBhVmDxy5g1s8eA==", "license": "Apache-2.0", "dependencies": { "@aws-amplify/data-schema-types": "*", @@ -31840,12 +31840,12 @@ }, "packages/auth-construct": { "name": "@aws-amplify/auth-construct", - "version": "1.4.0", + "version": "1.5.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", - "@aws-amplify/plugin-types": "^1.3.1", + "@aws-amplify/plugin-types": "^1.4.0", "@aws-sdk/util-arn-parser": "^3.568.0" }, "peerDependencies": { @@ -31855,12 +31855,12 @@ }, "packages/backend": { "name": "@aws-amplify/backend", - "version": "1.6.2", + "version": "1.7.0", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/backend-auth": "^1.3.0", - "@aws-amplify/backend-data": "^1.1.7", - "@aws-amplify/backend-function": "^1.7.4", + "@aws-amplify/backend-auth": "^1.4.0", + "@aws-amplify/backend-data": "^1.2.0", + "@aws-amplify/backend-function": "^1.7.5", "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/backend-secret": "^1.1.4", @@ -31868,7 +31868,7 @@ "@aws-amplify/client-config": "^1.5.2", "@aws-amplify/data-schema": "^1.0.0", "@aws-amplify/platform-core": "^1.2.0", - "@aws-amplify/plugin-types": "^1.3.1", + "@aws-amplify/plugin-types": "^1.4.0", "@aws-sdk/client-amplify": "^3.624.0", "lodash.snakecase": "^4.1.1" }, @@ -31901,13 +31901,13 @@ }, "packages/backend-auth": { "name": "@aws-amplify/backend-auth", - "version": "1.3.0", + "version": "1.4.0", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/auth-construct": "^1.4.0", + "@aws-amplify/auth-construct": "^1.5.0", "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", - "@aws-amplify/plugin-types": "^1.3.1" + "@aws-amplify/plugin-types": "^1.4.0" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", @@ -31924,14 +31924,15 @@ }, "packages/backend-data": { "name": "@aws-amplify/backend-data", - "version": "1.1.7", + "version": "1.2.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/data-construct": "^1.10.1", "@aws-amplify/data-schema-types": "^1.2.0", - "@aws-amplify/plugin-types": "^1.3.1" + "@aws-amplify/graphql-generator": "^0.5.0", + "@aws-amplify/plugin-types": "^1.4.0" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", @@ -31945,11 +31946,11 @@ }, "packages/backend-deployer": { "name": "@aws-amplify/backend-deployer", - "version": "1.1.8", + "version": "1.1.9", "license": "Apache-2.0", "dependencies": { "@aws-amplify/platform-core": "^1.2.0", - "@aws-amplify/plugin-types": "^1.3.1", + "@aws-amplify/plugin-types": "^1.4.0", "execa": "^8.0.1", "tsx": "^4.6.1" }, @@ -31960,12 +31961,13 @@ }, "packages/backend-function": { "name": "@aws-amplify/backend-function", - "version": "1.7.4", + "version": "1.7.5", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", - "@aws-amplify/plugin-types": "^1.3.1", + "@aws-amplify/plugin-types": "^1.4.0", + "@aws-sdk/client-s3": "^3.624.0", "execa": "^8.0.1" }, "devDependencies": { @@ -32061,19 +32063,19 @@ }, "packages/cli": { "name": "@aws-amplify/backend-cli", - "version": "1.4.1", + "version": "1.4.2", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/backend-deployer": "^1.1.8", + "@aws-amplify/backend-deployer": "^1.1.9", "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-secret": "^1.1.2", "@aws-amplify/cli-core": "^1.2.0", "@aws-amplify/client-config": "^1.5.1", "@aws-amplify/deployed-backend-client": "^1.4.1", "@aws-amplify/form-generator": "^1.0.3", - "@aws-amplify/model-generator": "^1.0.8", + "@aws-amplify/model-generator": "^1.0.9", "@aws-amplify/platform-core": "^1.2.0", - "@aws-amplify/plugin-types": "^1.3.1", + "@aws-amplify/plugin-types": "^1.4.0", "@aws-amplify/sandbox": "^1.2.5", "@aws-amplify/schema-generator": "^1.2.5", "@aws-sdk/client-amplify": "^3.624.0", @@ -32461,7 +32463,7 @@ }, "packages/model-generator": { "name": "@aws-amplify/model-generator", - "version": "1.0.8", + "version": "1.0.9", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.1.0", @@ -32469,7 +32471,7 @@ "@aws-amplify/graphql-generator": "^0.5.1", "@aws-amplify/graphql-types-generator": "^3.6.0", "@aws-amplify/platform-core": "^1.0.5", - "@aws-amplify/plugin-types": "^1.3.0", + "@aws-amplify/plugin-types": "^1.4.0", "@aws-sdk/client-appsync": "^3.624.0", "@aws-sdk/client-s3": "^3.624.0", "@aws-sdk/credential-providers": "^3.624.0", @@ -32515,7 +32517,7 @@ }, "packages/plugin-types": { "name": "@aws-amplify/plugin-types", - "version": "1.3.1", + "version": "1.4.0", "license": "Apache-2.0", "devDependencies": { "execa": "^5.1.1" diff --git a/packages/backend-data/package.json b/packages/backend-data/package.json index 5e0384b04e..67e1ee3f31 100644 --- a/packages/backend-data/package.json +++ b/packages/backend-data/package.json @@ -31,7 +31,8 @@ "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/data-construct": "^1.10.1", - "@aws-amplify/plugin-types": "^1.4.0", - "@aws-amplify/data-schema-types": "^1.2.0" + "@aws-amplify/data-schema-types": "^1.2.0", + "@aws-amplify/graphql-generator": "^0.5.1", + "@aws-amplify/plugin-types": "^1.4.0" } } diff --git a/packages/backend-data/src/app_sync_policy_generator.ts b/packages/backend-data/src/app_sync_policy_generator.ts index 9962d1922e..5db2eaea3d 100644 --- a/packages/backend-data/src/app_sync_policy_generator.ts +++ b/packages/backend-data/src/app_sync_policy_generator.ts @@ -14,7 +14,10 @@ export class AppSyncPolicyGenerator { /** * Initialize with the GraphqlAPI that the policies will be scoped to */ - constructor(private readonly graphqlApi: IGraphqlApi) { + constructor( + private readonly graphqlApi: IGraphqlApi, + private readonly modelIntrospectionSchemaArn?: string + ) { this.stack = Stack.of(graphqlApi); } /** @@ -29,13 +32,25 @@ export class AppSyncPolicyGenerator { .map((action) => actionToTypeMap[action]) // convert Type to resourceName .map((type) => [this.graphqlApi.arn, 'types', type, '*'].join('/')); - return new Policy(this.stack, `${this.policyPrefix}${this.policyCount++}`, { - statements: [ + + const statements = [ + new PolicyStatement({ + actions: ['appsync:GraphQL'], + resources, + }), + ]; + + if (this.modelIntrospectionSchemaArn) { + statements.push( new PolicyStatement({ - actions: ['appsync:GraphQL'], - resources, - }), - ], + actions: ['s3:GetObject'], + resources: [this.modelIntrospectionSchemaArn], + }) + ); + } + + return new Policy(this.stack, `${this.policyPrefix}${this.policyCount++}`, { + statements, }); } } diff --git a/packages/backend-data/src/factory.test.ts b/packages/backend-data/src/factory.test.ts index 51e2aabf45..91e1fdeff6 100644 --- a/packages/backend-data/src/factory.test.ts +++ b/packages/backend-data/src/factory.test.ts @@ -85,7 +85,6 @@ const createConstructContainerWithUserPoolAuthRegistered = ( authenticatedUserIamRole: new Role(stack, 'testAuthRole', { assumedBy: new ServicePrincipal('test.amazon.com'), }), - identityPoolId: 'identityPoolId', cfnResources: { cfnUserPool: new CfnUserPool(stack, 'CfnUserPool', {}), cfnUserPoolClient: new CfnUserPoolClient(stack, 'CfnUserPoolClient', { @@ -101,6 +100,7 @@ const createConstructContainerWithUserPoolAuthRegistered = ( ), }, groups: {}, + identityPoolId: 'identityPool', }, }), }); @@ -567,6 +567,23 @@ void describe('DataFactory', () => { }, ], }, + { + Action: 's3:GetObject', + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'modelIntrospectionSchemaBucketF566B665', + 'Arn', + ], + }, + '/modelIntrospectionSchema.json', + ], + ], + }, + }, ], }, Roles: [ @@ -675,6 +692,23 @@ void describe('DataFactory', () => { ], }, }, + { + Action: 's3:GetObject', + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'modelIntrospectionSchemaBucketF566B665', + 'Arn', + ], + }, + '/modelIntrospectionSchema.json', + ], + ], + }, + }, ], }, Roles: [ @@ -701,6 +735,23 @@ void describe('DataFactory', () => { ], }, }, + { + Action: 's3:GetObject', + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'modelIntrospectionSchemaBucketF566B665', + 'Arn', + ], + }, + '/modelIntrospectionSchema.json', + ], + ], + }, + }, ], }, Roles: [ diff --git a/packages/backend-data/src/factory.ts b/packages/backend-data/src/factory.ts index 578d0cbcbc..d81394f31f 100644 --- a/packages/backend-data/src/factory.ts +++ b/packages/backend-data/src/factory.ts @@ -7,7 +7,6 @@ import { ConstructFactory, ConstructFactoryGetInstanceProps, GenerateContainerEntryProps, - ReferenceAuthResources, ResourceProvider, } from '@aws-amplify/plugin-types'; import { @@ -17,6 +16,7 @@ import { TranslationBehavior, } from '@aws-amplify/data-construct'; import { GraphqlOutput } from '@aws-amplify/backend-output-schemas'; +import { generateModelsSync } from '@aws-amplify/graphql-generator'; import * as path from 'path'; import { AmplifyDataError, DataProps } from './types.js'; import { @@ -46,6 +46,10 @@ import { FunctionSchemaAccess, JsResolver, } from '@aws-amplify/data-schema-types'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; +import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; + +const modelIntrospectionSchemaKey = 'modelIntrospectionSchema.json'; /** * Singleton factory for AmplifyGraphqlApi constructs that can be used in Amplify project files. @@ -98,9 +102,9 @@ export class DataFactory implements ConstructFactory { this.props, buildConstructFactoryProvidedAuthConfig( props.constructContainer - .getConstructFactory< - ResourceProvider - >('AuthResources') + .getConstructFactory>( + 'AuthResources' + ) ?.getInstance(props) ), props, @@ -232,14 +236,21 @@ class DataGenerator implements ConstructContainerEntryGenerator { ...schemasLambdaFunctions, }); let amplifyApi = undefined; + let modelIntrospectionSchema: string | undefined = undefined; const isSandboxDeployment = scope.node.tryGetContext(CDKContextKey.DEPLOYMENT_TYPE) === 'sandbox'; try { + const combinedSchema = combineCDKSchemas(amplifyGraphqlDefinitions); + modelIntrospectionSchema = generateModelsSync({ + schema: combinedSchema.schema, + target: 'introspection', + })['model-introspection.json']; + amplifyApi = new AmplifyData(scope, this.name, { apiName: this.name, - definition: combineCDKSchemas(amplifyGraphqlDefinitions), + definition: combinedSchema, authorizationModes, outputStorageStrategy: this.outputStorageStrategy, functionNameMap, @@ -264,6 +275,20 @@ class DataGenerator implements ConstructContainerEntryGenerator { ); } + const modelIntrospectionSchemaBucket = new Bucket( + scope, + 'modelIntrospectionSchemaBucket', + { enforceSSL: true } + ); + new BucketDeployment(scope, 'modelIntrospectionSchemaBucketDeployment', { + // See https://github.com/aws-amplify/amplify-category-api/pull/1939 + memoryLimit: 1536, + destinationBucket: modelIntrospectionSchemaBucket, + sources: [ + Source.data(modelIntrospectionSchemaKey, modelIntrospectionSchema), + ], + }); + Tags.of(amplifyApi).add(TagName.FRIENDLY_NAME, this.name); /**; @@ -280,10 +305,15 @@ class DataGenerator implements ConstructContainerEntryGenerator { ssmEnvironmentEntriesGenerator.generateSsmEnvironmentEntries({ [`${this.name}_GRAPHQL_ENDPOINT`]: amplifyApi.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl, + [`${this.name}_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME`]: + modelIntrospectionSchemaBucket.bucketName, + [`${this.name}_MODEL_INTROSPECTION_SCHEMA_KEY`]: + modelIntrospectionSchemaKey, }); const policyGenerator = new AppSyncPolicyGenerator( - amplifyApi.resources.graphqlApi + amplifyApi.resources.graphqlApi, + `${modelIntrospectionSchemaBucket.bucketArn}/${modelIntrospectionSchemaKey}` ); schemasFunctionSchemaAccess.forEach((accessDefinition) => { diff --git a/packages/backend-function/package.json b/packages/backend-function/package.json index def869dd46..6a1c18c56a 100644 --- a/packages/backend-function/package.json +++ b/packages/backend-function/package.json @@ -10,6 +10,11 @@ "types": "./lib/index.d.ts", "import": "./lib/index.js", "require": "./lib/index.js" + }, + "./runtime": { + "types": "./lib/runtime/index.d.ts", + "import": "./lib/runtime/index.js", + "require": "./lib/runtime/index.js" } }, "main": "lib/index.js", @@ -22,11 +27,13 @@ "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/plugin-types": "^1.4.0", + "@aws-sdk/client-s3": "^3.624.0", "execa": "^8.0.1" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", "@aws-amplify/platform-core": "^1.1.0", + "@aws-sdk/client-s3": "^3.624.0", "@aws-sdk/client-ssm": "^3.624.0", "aws-sdk": "^2.1550.0", "uuid": "^9.0.1" diff --git a/packages/backend-function/runtime/package.json b/packages/backend-function/runtime/package.json new file mode 100644 index 0000000000..445dba172c --- /dev/null +++ b/packages/backend-function/runtime/package.json @@ -0,0 +1,8 @@ +{ + "name": "@aws-amplify/backend-function/runtime", + "main": "../dist/cjs/runtime/index.js", + "react-native": "../src/runtime/index.ts", + "browser": "../dist/esm/runtime/index.mjs", + "module": "../dist/esm/runtime/index.mjs", + "typings": "../dist/esm/runtime/index.d.ts" +} diff --git a/packages/backend-function/src/factory.ts b/packages/backend-function/src/factory.ts index 5d5a6938e2..96c7d1e4f5 100644 --- a/packages/backend-function/src/factory.ts +++ b/packages/backend-function/src/factory.ts @@ -44,6 +44,7 @@ import { FunctionEnvironmentTranslator } from './function_env_translator.js'; import { FunctionEnvironmentTypeGenerator } from './function_env_type_generator.js'; import { FunctionLayerArnParser } from './layer_parser.js'; import { convertFunctionSchedulesToRuleSchedules } from './schedule_parser.js'; +import { FunctionDataConfigGenerator } from './function_data_config_generator.js'; const functionStackType = 'function-Lambda'; @@ -425,6 +426,10 @@ class AmplifyFunction // This will be overwritten with the typed file at the end of synthesis functionEnvironmentTypeGenerator.generateProcessEnvShim(); + const functionClientConfigGenerator = new FunctionDataConfigGenerator(id); + + functionClientConfigGenerator.generateDataConfigShim(); + let functionLambda: NodejsFunction; try { functionLambda = new NodejsFunction(scope, `${id}-lambda`, { diff --git a/packages/backend-function/src/function_data_config_generator.test.ts b/packages/backend-function/src/function_data_config_generator.test.ts new file mode 100644 index 0000000000..f42d0de891 --- /dev/null +++ b/packages/backend-function/src/function_data_config_generator.test.ts @@ -0,0 +1,53 @@ +import { describe, it, mock } from 'node:test'; +import fs from 'fs'; +import { FunctionDataConfigGenerator } from './function_data_config_generator.js'; +import assert from 'assert'; +import { pathToFileURL } from 'url'; +import { FunctionEnvironmentTypeGenerator } from './function_env_type_generator.js'; + +void describe('FunctionDataConfigGenerator', () => { + void it('generates a type definition file', () => { + const fsOpenSyncMock = mock.method(fs, 'openSync'); + const fsWriteFileSyncMock = mock.method(fs, 'writeFileSync', () => null); + fsOpenSyncMock.mock.mockImplementation(() => 0); + const functionDataConfigGenerator = new FunctionDataConfigGenerator( + 'testFunction' + ); + const configExport = + 'export const { libraryOptions, resourceConfig } = await internalGetAmplifyClientConfiguration(env);'; + + functionDataConfigGenerator.generateDataConfigShim(); + + // assert type definition file path + assert.equal( + fsWriteFileSyncMock.mock.calls[0].arguments[0], + `${process.cwd()}/.amplify/generated/data-config/testFunction.ts` + ); + + // assert content + assert.ok( + fsWriteFileSyncMock.mock.calls[0].arguments[1] + ?.toString() + .includes(configExport) + ); + + mock.restoreAll(); + }); + + void it('generated data configuration file has valid syntax', async () => { + const functionDataConfigGenerator = new FunctionDataConfigGenerator( + 'testFunction' + ); + const filePath = `${process.cwd()}/.amplify/generated/data-config/testFunction.ts`; + + functionDataConfigGenerator.generateDataConfigShim(); + + // The data config shim depends upon the env shim, so we need to build it for the config to be importable + const functionEnvironmentTypeGenerator = + new FunctionEnvironmentTypeGenerator('testFunction'); + functionEnvironmentTypeGenerator.generateTypedProcessEnvShim(['TEST_ENV']); + + // import to validate syntax of data config file + await import(pathToFileURL(filePath).toString()); + }); +}); diff --git a/packages/backend-function/src/function_data_config_generator.ts b/packages/backend-function/src/function_data_config_generator.ts new file mode 100644 index 0000000000..25d48682c9 --- /dev/null +++ b/packages/backend-function/src/function_data_config_generator.ts @@ -0,0 +1,45 @@ +import fs from 'fs'; +import path from 'path'; + +const lambdaDataConfigTemplate = ( + functionName: string +) => `// This file is auto-generated by Amplify. Edits will be overwritten. +import { internalGetAmplifyClientConfiguration } from "@aws-amplify/backend-function/runtime"; +import { env } from "../env/${functionName}"; +export const { libraryOptions, resourceConfig } = await internalGetAmplifyClientConfiguration(env); +`; + +/** + * Generates the data configuration imports + */ +export class FunctionDataConfigGenerator { + private typeDefFilePath: string; + + private indentation: string = ' '; + + /** + * Initialize data configuration file name and location + */ + constructor(private readonly functionName: string) { + this.typeDefFilePath = `${process.cwd()}/.amplify/generated/data-config/${ + this.functionName + }.ts`; + } + + /** + * Generate data-config shim + */ + generateDataConfigShim = () => { + this.writeShimFile(lambdaDataConfigTemplate(this.functionName)); + }; + + private writeShimFile = (content: string) => { + const typeDefFileDirname = path.dirname(this.typeDefFilePath); + + if (!fs.existsSync(typeDefFileDirname)) { + fs.mkdirSync(typeDefFileDirname, { recursive: true }); + } + + fs.writeFileSync(this.typeDefFilePath, content); + }; +} diff --git a/packages/backend-function/src/function_env_type_generator.test.ts b/packages/backend-function/src/function_env_type_generator.test.ts index 15a913479c..315ae7a2a9 100644 --- a/packages/backend-function/src/function_env_type_generator.test.ts +++ b/packages/backend-function/src/function_env_type_generator.test.ts @@ -1,6 +1,5 @@ import { describe, it, mock } from 'node:test'; import fs from 'fs'; -import fsp from 'fs/promises'; import { FunctionEnvironmentTypeGenerator } from './function_env_type_generator.js'; import assert from 'assert'; import { pathToFileURL } from 'url'; @@ -57,7 +56,6 @@ void describe('FunctionEnvironmentTypeGenerator', () => { }); void it('generated type definition file has valid syntax', async () => { - const targetDirectory = await fsp.mkdtemp('func_env_type_gen_test'); const functionEnvironmentTypeGenerator = new FunctionEnvironmentTypeGenerator('testFunction'); const filePath = `${process.cwd()}/.amplify/generated/env/testFunction.ts`; @@ -66,8 +64,6 @@ void describe('FunctionEnvironmentTypeGenerator', () => { // import to validate syntax of type definition file await import(pathToFileURL(filePath).toString()); - - await fsp.rm(targetDirectory, { recursive: true, force: true }); }); void it('does not generate duplicate environment variables', () => { diff --git a/packages/backend-function/src/runtime/get_amplify_clients_configuration.test.ts b/packages/backend-function/src/runtime/get_amplify_clients_configuration.test.ts new file mode 100644 index 0000000000..1d33cb0731 --- /dev/null +++ b/packages/backend-function/src/runtime/get_amplify_clients_configuration.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { NoSuchKey, S3, S3ServiceException } from '@aws-sdk/client-s3'; + +import { internalGetAmplifyClientConfiguration } from './get_amplify_clients_configuration.js'; + +const validEnv = { + AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME: + 'TEST_VALUE for AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME', + AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY: + 'TEST_VALUE for AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY', + AWS_ACCESS_KEY_ID: 'TEST_VALUE for AWS_ACCESS_KEY_ID', + AWS_SECRET_ACCESS_KEY: 'TEST_VALUE for AWS_SECRET_ACCESS_KEY', + AWS_SESSION_TOKEN: 'TEST_VALUE for AWS_SESSION_TOKEN', + AWS_REGION: 'TEST_VALUE for AWS_REGION', + AMPLIFY_DATA_GRAPHQL_ENDPOINT: 'TEST_VALUE for AMPLIFY_DATA_GRAPHQL_ENDPOINT', +}; + +let mockS3Client: S3; + +void describe('internalGetAmplifyClientConfiguration', () => { + beforeEach(() => { + mockS3Client = new S3(); + }); + + Object.keys(validEnv).forEach((envFieldToExclude) => { + void it(`returns empty config objects when ${envFieldToExclude} is not included`, async () => { + const env = { ...validEnv } as Record; + delete env[envFieldToExclude]; + assert.deepEqual(await internalGetAmplifyClientConfiguration(env), { + resourceConfig: {}, + libraryOptions: {}, + }); + }); + + void it(`returns empty config objects when ${envFieldToExclude} is not a string`, async () => { + const env = { ...validEnv } as Record; + env[envFieldToExclude] = 123; + assert.deepEqual(await internalGetAmplifyClientConfiguration(env), { + resourceConfig: {}, + libraryOptions: {}, + }); + }); + }); + + void it('raises a custom error message when the model introspection schema is missing from the s3 bucket', async () => { + const s3ClientSendMock = mock.method(mockS3Client, 'send', async () => { + throw new NoSuchKey({ message: 'TEST_ERROR', $metadata: {} }); + }); + mock.method(mockS3Client, 'send', s3ClientSendMock); + + await assert.rejects( + async () => + await internalGetAmplifyClientConfiguration(validEnv, mockS3Client), + new Error( + 'Error retrieving the schema from S3. Please confirm that your project has a `defineData` included in the `defineBackend` definition.' + ) + ); + }); + + void it('raises a custom error message when there is a S3ServiceException error retrieving the model introspection schema from the s3 bucket', async () => { + const s3ClientSendMock = mock.method(mockS3Client, 'send', async () => { + throw new S3ServiceException({ + name: 'TEST_ERROR', + message: 'TEST_MESSAGE', + $fault: 'server', + $metadata: {}, + }); + }); + mock.method(mockS3Client, 'send', s3ClientSendMock); + + await assert.rejects( + async () => + await internalGetAmplifyClientConfiguration(validEnv, mockS3Client), + new Error( + 'Error retrieving the schema from S3. You may need to grant this function authorization on the schema. TEST_ERROR: TEST_MESSAGE.' + ) + ); + }); + + void it('re-raises a non-S3 error received when retrieving the model introspection schema from the s3 bucket', async () => { + const s3ClientSendMock = mock.method(mockS3Client, 'send', async () => { + throw new Error('Test Error'); + }); + mock.method(mockS3Client, 'send', s3ClientSendMock); + + await assert.rejects( + async () => + await internalGetAmplifyClientConfiguration(validEnv, mockS3Client), + new Error('Test Error') + ); + }); + + void it('returns the expected libraryOptions and resourceConfig values in the happy case', async () => { + const s3ClientSendMock = mock.method(mockS3Client, 'send', () => { + return Promise.resolve({ + Body: { + transformToString: () => JSON.stringify({ testSchema: 'TESTING' }), + }, + }); + }); + mock.method(mockS3Client, 'send', s3ClientSendMock); + + const { resourceConfig, libraryOptions } = + await internalGetAmplifyClientConfiguration(validEnv, mockS3Client); + + assert.deepEqual( + await libraryOptions.Auth.credentialsProvider.getCredentialsAndIdentityId?.(), + { + credentials: { + accessKeyId: 'TEST_VALUE for AWS_ACCESS_KEY_ID', + secretAccessKey: 'TEST_VALUE for AWS_SECRET_ACCESS_KEY', + sessionToken: 'TEST_VALUE for AWS_SESSION_TOKEN', + }, + } + ); + assert.deepEqual( + await libraryOptions.Auth.credentialsProvider.clearCredentialsAndIdentityId?.(), + undefined + ); + + assert.deepEqual(resourceConfig, { + API: { + GraphQL: { + endpoint: 'TEST_VALUE for AMPLIFY_DATA_GRAPHQL_ENDPOINT', + region: 'TEST_VALUE for AWS_REGION', + defaultAuthMode: 'iam', + modelIntrospection: { testSchema: 'TESTING' }, + }, + }, + }); + }); +}); diff --git a/packages/backend-function/src/runtime/get_amplify_clients_configuration.ts b/packages/backend-function/src/runtime/get_amplify_clients_configuration.ts new file mode 100644 index 0000000000..4b12f7ab08 --- /dev/null +++ b/packages/backend-function/src/runtime/get_amplify_clients_configuration.ts @@ -0,0 +1,147 @@ +import { + GetObjectCommand, + NoSuchKey, + S3Client, + S3ServiceException, +} from '@aws-sdk/client-s3'; + +type DataClientEnv = { + /* eslint-disable @typescript-eslint/naming-convention */ + AMPLIFY_DATA_GRAPHQL_ENDPOINT: string; + AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME: string; + AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY: string; + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + AWS_SESSION_TOKEN: string; + AWS_REGION: string; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +const isDataClientEnv = (env: unknown): env is DataClientEnv => { + return ( + env !== null && + typeof env === 'object' && + 'AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME' in env && + 'AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY' in env && + 'AWS_ACCESS_KEY_ID' in env && + 'AWS_SECRET_ACCESS_KEY' in env && + 'AWS_SESSION_TOKEN' in env && + 'AWS_REGION' in env && + 'AMPLIFY_DATA_GRAPHQL_ENDPOINT' in env && + typeof env.AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME === + 'string' && + typeof env.AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY === 'string' && + typeof env.AWS_ACCESS_KEY_ID === 'string' && + typeof env.AWS_SECRET_ACCESS_KEY === 'string' && + typeof env.AWS_SESSION_TOKEN === 'string' && + typeof env.AWS_REGION === 'string' && + typeof env.AMPLIFY_DATA_GRAPHQL_ENDPOINT === 'string' + ); +}; + +const getResourceConfig = ( + env: DataClientEnv, + modelIntrospectionSchema: object +) => { + return { + API: { + GraphQL: { + endpoint: env.AMPLIFY_DATA_GRAPHQL_ENDPOINT, + region: env.AWS_REGION, + defaultAuthMode: 'iam' as const, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelIntrospection: modelIntrospectionSchema as any, + }, + }, + }; +}; + +const getLibraryOptions = (env: DataClientEnv) => { + return { + Auth: { + credentialsProvider: { + getCredentialsAndIdentityId: async () => ({ + credentials: { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + sessionToken: env.AWS_SESSION_TOKEN, + }, + }), + clearCredentialsAndIdentityId: () => { + /* noop */ + }, + }, + }, + }; +}; + +type InvalidConfig = unknown & { + invalidType: 'This function needs to be granted `authorization((allow) => [allow.resource(fcn)])` on the data schema.'; +}; + +type DataClientError = { + resourceConfig: InvalidConfig; + libraryOptions: InvalidConfig; +}; + +type DataClientConfig = { + resourceConfig: ReturnType; + libraryOptions: ReturnType; +}; + +type DataClientReturn = T extends DataClientEnv + ? DataClientConfig + : DataClientError; + +/** + * Generate the `resourceConfig` and `libraryOptions` need to configure + * Amplify for the data client in a lambda. + * + * Your function needs to be granted resource access on your schema for this to work + * `a.schema(...).authorization((allow) => [a.resource(myFunction)])` + * @param env - The environment variables for the data client + * @returns An object containing the `resourceConfig` and `libraryOptions` + */ +export const internalGetAmplifyClientConfiguration = async ( + env: T, + s3Client?: S3Client +): Promise> => { + if (!s3Client) { + s3Client = new S3Client(); + } + + if (!isDataClientEnv(env)) { + return { resourceConfig: {}, libraryOptions: {} } as DataClientReturn; + } + let modelIntrospectionSchema: object; + + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: env.AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_BUCKET_NAME, + Key: env.AMPLIFY_DATA_MODEL_INTROSPECTION_SCHEMA_KEY, + }) + ); + const modelIntrospectionSchemaJson = + await response.Body?.transformToString(); + modelIntrospectionSchema = JSON.parse(modelIntrospectionSchemaJson ?? '{}'); + } catch (caught) { + if (caught instanceof NoSuchKey) { + throw new Error( + 'Error retrieving the schema from S3. Please confirm that your project has a `defineData` included in the `defineBackend` definition.' + ); + } else if (caught instanceof S3ServiceException) { + throw new Error( + `Error retrieving the schema from S3. You may need to grant this function authorization on the schema. ${caught.name}: ${caught.message}.` + ); + } else { + throw caught; + } + } + + const libraryOptions = getLibraryOptions(env); + + const resourceConfig = getResourceConfig(env, modelIntrospectionSchema); + + return { resourceConfig, libraryOptions } as DataClientReturn; +}; diff --git a/packages/backend-function/src/runtime/index.ts b/packages/backend-function/src/runtime/index.ts new file mode 100644 index 0000000000..dd9e4fe8f5 --- /dev/null +++ b/packages/backend-function/src/runtime/index.ts @@ -0,0 +1 @@ +export { internalGetAmplifyClientConfiguration } from './get_amplify_clients_configuration.js';