diff --git a/.changeset/brave-cheetahs-repeat.md b/.changeset/brave-cheetahs-repeat.md new file mode 100644 index 00000000000..d74a469e25a --- /dev/null +++ b/.changeset/brave-cheetahs-repeat.md @@ -0,0 +1,7 @@ +--- +'@aws-amplify/backend': minor +'@aws-amplify/backend-function': minor +'@aws-amplify/backend-data': patch +--- + +Add lambda data client diff --git a/package-lock.json b/package-lock.json index 97fb224519f..6a55c53ed8b 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.16.1", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema/-/data-schema-1.16.1.tgz", + "integrity": "sha512-ThEiEoDbGfU03a2wVpdW4VORLrUkrlWMb9Xc6kI6I296+Gk0DHKNmQUFov4nlqxUIBe3lntJUcZSCMWJZTq4ZQ==", "license": "Apache-2.0", "dependencies": { "@aws-amplify/data-schema-types": "*", @@ -31562,7 +31562,7 @@ "@aws-amplify/backend-secret": "^1.1.4", "@aws-amplify/backend-storage": "^1.2.3", "@aws-amplify/client-config": "^1.5.2", - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/platform-core": "^1.2.1", "@aws-amplify/plugin-types": "^1.5.0", "@aws-sdk/client-amplify": "^3.624.0", @@ -31627,11 +31627,12 @@ "@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.5.0" + "@aws-amplify/graphql-generator": "^0.5.1", + "@aws-amplify/plugin-types": "^1.4.0" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/platform-core": "^1.2.1" }, "peerDependencies": { @@ -31663,11 +31664,13 @@ "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/plugin-types": "^1.5.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.2.1", + "@aws-sdk/client-s3": "^3.624.0", "@aws-sdk/client-ssm": "^3.624.0", "aws-sdk": "^2.1550.0", "uuid": "^9.0.1" @@ -32107,7 +32110,7 @@ "@aws-amplify/backend-ai": "^1.0.0", "@aws-amplify/backend-secret": "^1.1.4", "@aws-amplify/client-config": "^1.5.1", - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/deployed-backend-client": "^1.4.1", "@aws-amplify/platform-core": "^1.1.0", "@aws-amplify/plugin-types": "^1.3.1", diff --git a/packages/backend-data/package.json b/packages/backend-data/package.json index 0ec2b12a4c8..9afce361886 100644 --- a/packages/backend-data/package.json +++ b/packages/backend-data/package.json @@ -19,7 +19,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/backend-platform-test-stubs": "^0.3.6", "@aws-amplify/platform-core": "^1.2.1" }, @@ -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.5.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 9962d1922ea..5db2eaea3d7 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 51e2aabf458..91e1fdeff6c 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 92436ec6b17..98d48b0667e 100644 --- a/packages/backend-data/src/factory.ts +++ b/packages/backend-data/src/factory.ts @@ -18,6 +18,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 { @@ -40,13 +41,17 @@ import { CDKContextKey, TagName, } from '@aws-amplify/platform-core'; -import { Aspects, IAspect, Tags } from 'aws-cdk-lib'; +import { Aspects, IAspect, RemovalPolicy, Tags } from 'aws-cdk-lib'; import { convertJsResolverDefinition } from './convert_js_resolvers.js'; import { AppSyncPolicyGenerator } from './app_sync_policy_generator.js'; 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. @@ -233,14 +238,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, @@ -265,6 +277,24 @@ class DataGenerator implements ConstructContainerEntryGenerator { ); } + const modelIntrospectionSchemaBucket = new Bucket( + scope, + 'modelIntrospectionSchemaBucket', + { + enforceSSL: true, + autoDeleteObjects: true, + removalPolicy: RemovalPolicy.DESTROY, + } + ); + 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); /**; @@ -281,10 +311,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/API.md b/packages/backend-function/API.md index 500972914db..fed504b411d 100644 --- a/packages/backend-function/API.md +++ b/packages/backend-function/API.md @@ -12,8 +12,23 @@ import { LogLevel } from '@aws-amplify/plugin-types'; import { LogRetention } from '@aws-amplify/plugin-types'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; import { ResourceProvider } from '@aws-amplify/plugin-types'; +import { S3Client } from '@aws-sdk/client-s3'; import { StackProvider } from '@aws-amplify/plugin-types'; +declare namespace __export__runtime { + export { + getAmplifyDataClientConfig, + DataClientConfig, + DataClientEnv, + DataClientError, + DataClientReturn, + InvalidConfig, + LibraryOptions, + ResourceConfig + } +} +export { __export__runtime } + // @public (undocumented) export type AddEnvironmentFactory = { addEnvironment: (key: string, value: string | BackendSecret) => void; @@ -22,6 +37,32 @@ export type AddEnvironmentFactory = { // @public (undocumented) export type CronSchedule = `${string} ${string} ${string} ${string} ${string}` | `${string} ${string} ${string} ${string} ${string} ${string}`; +// @public (undocumented) +type DataClientConfig = { + resourceConfig: ResourceConfig; + libraryOptions: LibraryOptions; +}; + +// @public (undocumented) +type DataClientEnv = { + 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; +}; + +// @public (undocumented) +type DataClientError = { + resourceConfig: InvalidConfig; + libraryOptions: InvalidConfig; +}; + +// @public (undocumented) +type DataClientReturn = T extends DataClientEnv ? DataClientConfig : DataClientError; + // @public export const defineFunction: (props?: FunctionProps) => ConstructFactory & ResourceAccessAcceptorFactory & AddEnvironmentFactory & StackProvider>; @@ -64,9 +105,45 @@ export type FunctionProps = { // @public (undocumented) export type FunctionSchedule = TimeInterval | CronSchedule; +// @public +const getAmplifyDataClientConfig: (env: T, s3Client?: S3Client) => Promise>; + +// @public (undocumented) +type InvalidConfig = unknown & { + invalidType: 'This function needs to be granted `authorization((allow) => [allow.resource(fcn)])` on the data schema.'; +}; + +// @public (undocumented) +type LibraryOptions = { + Auth: { + credentialsProvider: { + getCredentialsAndIdentityId: () => Promise<{ + credentials: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + }; + }>; + clearCredentialsAndIdentityId: () => void; + }; + }; +}; + // @public (undocumented) export type NodeVersion = 16 | 18 | 20 | 22; +// @public (undocumented) +type ResourceConfig = { + API: { + GraphQL: { + endpoint: string; + region: string; + defaultAuthMode: 'iam'; + modelIntrospection: any; + }; + }; +}; + // @public (undocumented) export type TimeInterval = `every ${number}m` | `every ${number}h` | `every day` | `every week` | `every month` | `every year`; diff --git a/packages/backend-function/api-extractor.json b/packages/backend-function/api-extractor.json index 0f56de03f66..cc2ebea8cf9 100644 --- a/packages/backend-function/api-extractor.json +++ b/packages/backend-function/api-extractor.json @@ -1,3 +1,4 @@ { - "extends": "../../api-extractor.base.json" + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/lib/index.internal.d.ts" } diff --git a/packages/backend-function/package.json b/packages/backend-function/package.json index 212f8567ed9..de43cf23183 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.5.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.2.1", + "@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/src/function_env_type_generator.test.ts b/packages/backend-function/src/function_env_type_generator.test.ts index 64d5588bce5..0865afa8183 100644 --- a/packages/backend-function/src/function_env_type_generator.test.ts +++ b/packages/backend-function/src/function_env_type_generator.test.ts @@ -66,7 +66,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`; @@ -75,8 +74,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/index.internal.ts b/packages/backend-function/src/index.internal.ts new file mode 100644 index 00000000000..cc29b4a317a --- /dev/null +++ b/packages/backend-function/src/index.internal.ts @@ -0,0 +1,10 @@ +export * from './index.js'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import * as __export__runtime from './runtime/index.js'; + +/* + Api-extractor does not ([yet](https://github.com/microsoft/rushstack/issues/1596)) support multiple package entry points + Because this package has a submodule export, we are working around this issue by including that export here and directing api-extract to this entry point instead + This allows api-extractor to pick up the submodule exports in its analysis + */ +export { __export__runtime }; 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 00000000000..e2e5d45407f --- /dev/null +++ b/packages/backend-function/src/runtime/get_amplify_clients_configuration.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { NoSuchKey, S3, S3ServiceException } from '@aws-sdk/client-s3'; + +import { getAmplifyDataClientConfig } 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('getAmplifyDataClientConfig', () => { + 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 getAmplifyDataClientConfig(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 getAmplifyDataClientConfig(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 getAmplifyDataClientConfig(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 getAmplifyDataClientConfig(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 getAmplifyDataClientConfig(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 getAmplifyDataClientConfig( + 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 00000000000..2f377fe3492 --- /dev/null +++ b/packages/backend-function/src/runtime/get_amplify_clients_configuration.ts @@ -0,0 +1,179 @@ +import { + GetObjectCommand, + NoSuchKey, + S3Client, + S3ServiceException, +} from '@aws-sdk/client-s3'; + +export 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' + ); +}; + +/* eslint-disable @typescript-eslint/naming-convention */ +export type ResourceConfig = { + API: { + GraphQL: { + endpoint: string; + region: string; + defaultAuthMode: 'iam'; + // Using `any` to avoid reproducing 100+ lines of typing to match the expected shape defined in aws-amplify: + // https://github.com/aws-amplify/amplify-js/blob/main/packages/core/src/singleton/API/types.ts#L143-L153 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelIntrospection: any; + }; + }; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +const getResourceConfig = ( + env: DataClientEnv, + modelIntrospectionSchema: object +): ResourceConfig => { + return { + API: { + GraphQL: { + endpoint: env.AMPLIFY_DATA_GRAPHQL_ENDPOINT, + region: env.AWS_REGION, + defaultAuthMode: 'iam' as const, + + modelIntrospection: modelIntrospectionSchema, + }, + }, + }; +}; + +export type LibraryOptions = { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + Auth: { + credentialsProvider: { + getCredentialsAndIdentityId: () => Promise<{ + credentials: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + }; + }>; + clearCredentialsAndIdentityId: () => void; + }; + }; +}; + +const getLibraryOptions = (env: DataClientEnv): LibraryOptions => { + 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 */ + }, + }, + }, + }; +}; + +export type InvalidConfig = unknown & { + invalidType: 'This function needs to be granted `authorization((allow) => [allow.resource(fcn)])` on the data schema.'; +}; + +export type DataClientError = { + resourceConfig: InvalidConfig; + libraryOptions: InvalidConfig; +}; + +export type DataClientConfig = { + resourceConfig: ResourceConfig; + libraryOptions: LibraryOptions; +}; + +export 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 getAmplifyDataClientConfig = 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 00000000000..07edc4c883b --- /dev/null +++ b/packages/backend-function/src/runtime/index.ts @@ -0,0 +1,10 @@ +export { + getAmplifyDataClientConfig, + DataClientConfig, + DataClientEnv, + DataClientError, + DataClientReturn, + InvalidConfig, + LibraryOptions, + ResourceConfig, +} from './get_amplify_clients_configuration.js'; diff --git a/packages/backend/API.md b/packages/backend/API.md index c9d790427f0..d677b05e814 100644 --- a/packages/backend/API.md +++ b/packages/backend/API.md @@ -25,6 +25,7 @@ import { defineFunction } from '@aws-amplify/backend-function'; import { defineStorage } from '@aws-amplify/backend-storage'; import { FunctionResources } from '@aws-amplify/plugin-types'; import { GenerateContainerEntryProps } from '@aws-amplify/plugin-types'; +import { getAmplifyDataClientConfig } from '@aws-amplify/backend-function/runtime'; import { ImportPathVerifier } from '@aws-amplify/plugin-types'; import { referenceAuth } from '@aws-amplify/backend-auth'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; @@ -33,6 +34,13 @@ import { SsmEnvironmentEntriesGenerator } from '@aws-amplify/plugin-types'; import { SsmEnvironmentEntry } from '@aws-amplify/plugin-types'; import { Stack } from 'aws-cdk-lib'; +declare namespace __export__function__runtime { + export { + getAmplifyDataClientConfig + } +} +export { __export__function__runtime } + export { a } export { AuthCfnResources } diff --git a/packages/backend/package.json b/packages/backend/package.json index 106acaac429..60261d7a984 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,6 +11,11 @@ "import": "./lib/index.js", "require": "./lib/index.js" }, + "./function/runtime": { + "types": "./lib/function/runtime/index.d.ts", + "import": "./lib/function/runtime/index.js", + "require": "./lib/function/runtime/index.js" + }, "./types/platform": { "types": "./lib/types/platform.d.ts" } @@ -25,7 +30,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/backend-auth": "^1.4.1", "@aws-amplify/backend-function": "^1.8.0", "@aws-amplify/backend-data": "^1.2.1", diff --git a/packages/backend/src/function/runtime/index.ts b/packages/backend/src/function/runtime/index.ts new file mode 100644 index 00000000000..a82f939faa6 --- /dev/null +++ b/packages/backend/src/function/runtime/index.ts @@ -0,0 +1 @@ +export { getAmplifyDataClientConfig } from '@aws-amplify/backend-function/runtime'; diff --git a/packages/backend/src/index.internal.ts b/packages/backend/src/index.internal.ts index edcc5711d60..dcb3dad53c3 100644 --- a/packages/backend/src/index.internal.ts +++ b/packages/backend/src/index.internal.ts @@ -1,4 +1,6 @@ export * from './index.js'; +// eslint-disable-next-line @typescript-eslint/naming-convention +import * as __export__function__runtime from './function/runtime/index.js'; /* Api-extractor does not ([yet](https://github.com/microsoft/rushstack/issues/1596)) support multiple package entry points @@ -7,3 +9,4 @@ export * from './index.js'; */ export * from './types/platform.js'; +export { __export__function__runtime }; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 9943548811e..a50b9bd1bf4 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -11,7 +11,7 @@ "@aws-amplify/backend-ai": "^1.0.0", "@aws-amplify/backend-secret": "^1.1.4", "@aws-amplify/client-config": "^1.5.1", - "@aws-amplify/data-schema": "^1.0.0", + "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/deployed-backend-client": "^1.4.1", "@aws-amplify/platform-core": "^1.1.0", "@aws-amplify/plugin-types": "^1.3.1", diff --git a/packages/integration-tests/src/test-e2e/deployment/data_access_from_function_project.deployment.test.ts b/packages/integration-tests/src/test-e2e/deployment/data_access_from_function_project.deployment.test.ts new file mode 100644 index 00000000000..14bd0fe025f --- /dev/null +++ b/packages/integration-tests/src/test-e2e/deployment/data_access_from_function_project.deployment.test.ts @@ -0,0 +1,4 @@ +import { defineDeploymentTest } from './deployment.test.template.js'; +import { DataAccessFromFunctionTestProjectCreator } from '../../test-project-setup/data_access_from_function_project.js'; + +defineDeploymentTest(new DataAccessFromFunctionTestProjectCreator()); diff --git a/packages/integration-tests/src/test-e2e/sandbox/data_access_from_function_project.sandbox.test.ts b/packages/integration-tests/src/test-e2e/sandbox/data_access_from_function_project.sandbox.test.ts new file mode 100644 index 00000000000..c71b261b0ef --- /dev/null +++ b/packages/integration-tests/src/test-e2e/sandbox/data_access_from_function_project.sandbox.test.ts @@ -0,0 +1,4 @@ +import { defineSandboxTest } from './sandbox.test.template.js'; +import { DataAccessFromFunctionTestProjectCreator } from '../../test-project-setup/data_access_from_function_project.js'; + +defineSandboxTest(new DataAccessFromFunctionTestProjectCreator()); diff --git a/packages/integration-tests/src/test-project-setup/data_access_from_function_project.ts b/packages/integration-tests/src/test-project-setup/data_access_from_function_project.ts new file mode 100644 index 00000000000..b85a67f026e --- /dev/null +++ b/packages/integration-tests/src/test-project-setup/data_access_from_function_project.ts @@ -0,0 +1,164 @@ +import { TestProjectBase } from './test_project_base.js'; +import fs from 'fs/promises'; +import { createEmptyAmplifyProject } from './create_empty_amplify_project.js'; +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { TestProjectCreator } from './test_project_creator.js'; +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { generateClientConfig } from '@aws-amplify/client-config'; +import { + ApolloClient, + ApolloLink, + HttpLink, + InMemoryCache, +} from '@apollo/client/core'; +import { AUTH_TYPE, createAuthLink } from 'aws-appsync-auth-link'; +import { gql } from 'graphql-tag'; +import assert from 'assert'; +import { NormalizedCacheObject } from '@apollo/client'; +import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; + +/** + * Creates the data and function test project. + */ +export class DataAccessFromFunctionTestProjectCreator + implements TestProjectCreator +{ + readonly name = 'data-access-from-function'; + + /** + * Creates project creator. + */ + constructor( + private readonly cfnClient: CloudFormationClient = new CloudFormationClient( + e2eToolingClientConfig + ), + private readonly amplifyClient: AmplifyClient = new AmplifyClient( + e2eToolingClientConfig + ) + ) {} + + createProject = async (e2eProjectDir: string): Promise => { + const { projectName, projectRoot, projectAmplifyDir } = + await createEmptyAmplifyProject(this.name, e2eProjectDir); + + const project = new DataAccessFromFunctionTestProject( + projectName, + projectRoot, + projectAmplifyDir, + this.cfnClient, + this.amplifyClient + ); + await fs.cp( + project.sourceProjectAmplifyDirURL, + project.projectAmplifyDirPath, + { + recursive: true, + } + ); + return project; + }; +} + +/** + * The data and function test project. + */ +class DataAccessFromFunctionTestProject extends TestProjectBase { + readonly sourceProjectDirPath = + '../../src/test-projects/data_access_from_function'; + + readonly sourceProjectAmplifyDirSuffix = `${this.sourceProjectDirPath}/amplify`; + + readonly sourceProjectAmplifyDirURL: URL = new URL( + this.sourceProjectAmplifyDirSuffix, + import.meta.url + ); + + /** + * Create a test project instance. + */ + constructor( + name: string, + projectDirPath: string, + projectAmplifyDirPath: string, + cfnClient: CloudFormationClient, + amplifyClient: AmplifyClient + ) { + super( + name, + projectDirPath, + projectAmplifyDirPath, + cfnClient, + amplifyClient + ); + } + + override async assertPostDeployment( + backendId: BackendIdentifier + ): Promise { + await super.assertPostDeployment(backendId); + + const clientConfig = await generateClientConfig(backendId, '1.1'); + if (!clientConfig.data?.url) { + throw new Error('Data and function project must include data'); + } + if (!clientConfig.data.api_key) { + throw new Error('Data and function project must include api_key'); + } + + const httpLink = new HttpLink({ uri: clientConfig.data.url }); + const link = ApolloLink.from([ + createAuthLink({ + url: clientConfig.data.url, + region: clientConfig.data.aws_region, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey: clientConfig.data.api_key, + }, + }), + // see https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/473#issuecomment-543029072 + httpLink, + ]); + const apolloClient = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + await this.assertDataFunctionCallSucceeds(apolloClient); + await this.assertNoopWithImportCallSucceeds(apolloClient); + } + + private assertDataFunctionCallSucceeds = async ( + apolloClient: ApolloClient + ): Promise => { + // The todoCount query calls the todoCount lambda + const response = await apolloClient.query({ + query: gql` + query todoCount { + todoCount + } + `, + variables: {}, + }); + + // Assert the expected lambda call return + assert.deepEqual(response.data, { todoCount: 0 }); + }; + + private assertNoopWithImportCallSucceeds = async ( + apolloClient: ApolloClient + ): Promise => { + // The noopImport query calls the noopImport lambda + const response = await apolloClient.query({ + query: gql` + query noopImport { + noopImport + } + `, + variables: {}, + }); + + // Assert the expected lambda call return + assert.deepEqual(response.data, { noopImport: 'STATIC TEST RESPONSE' }); + }; +} diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/backend.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/backend.ts new file mode 100644 index 00000000000..bd208c8043f --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/backend.ts @@ -0,0 +1,6 @@ +import { defineBackend } from '@aws-amplify/backend'; +import { data } from './data/resource.js'; +import { todoCount } from './functions/todo-count/resource.js'; +import { customerS3Import } from './functions/customer-s3-import/resource.js'; + +const backend = defineBackend({ data, todoCount, customerS3Import }); diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/data/resource.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/data/resource.ts new file mode 100644 index 00000000000..4c3442bdc72 --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/data/resource.ts @@ -0,0 +1,42 @@ +import { a, ClientSchema, defineData } from '@aws-amplify/backend'; +import { todoCount } from '../functions/todo-count/resource.js'; +import { customerS3Import } from '../functions/customer-s3-import/resource.js'; + +const schema = a + .schema({ + Todo: a + .model({ + title: a.string().required(), + done: a.boolean().default(false), // default value is false + }) + .authorization((allow) => [allow.publicApiKey()]), + todoCount: a + .query() + .arguments({}) + .returns(a.integer()) + .handler(a.handler.function(todoCount)) + .authorization((allow) => [allow.publicApiKey()]), + noopImport: a + .query() + .arguments({}) + .returns(a.string()) + .handler(a.handler.function(customerS3Import)) + .authorization((allow) => [allow.publicApiKey()]), + }) + .authorization((allow) => [ + allow.resource(todoCount), + allow.resource(customerS3Import), + ]); + +export type Schema = ClientSchema; + +export const data = defineData({ + schema, + authorizationModes: { + defaultAuthorizationMode: 'apiKey', + // API Key is used for a.allow.public() rules + apiKeyAuthorizationMode: { + expiresInDays: 30, + }, + }, +}); diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/handler.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/handler.ts new file mode 100644 index 00000000000..fc4b448637f --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/handler.ts @@ -0,0 +1,22 @@ +import type { Handler } from 'aws-lambda'; +import { Amplify } from 'aws-amplify'; +import { generateClient } from 'aws-amplify/data'; +import type { Schema } from '../../data/resource.js'; +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime'; +// @ts-ignore +import { env } from '$amplify/env/customer-s3-import.js'; +import { S3Client } from '@aws-sdk/client-s3'; + +const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig( + env +); + +Amplify.configure(resourceConfig, libraryOptions); + +const client = generateClient(); + +export const handler: Handler = async () => { + const _s3Client = new S3Client(); + const _todos = await client.models.Todo.list(); + return 'STATIC TEST RESPONSE'; +}; diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/resource.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/resource.ts new file mode 100644 index 00000000000..c985d40794d --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/customer-s3-import/resource.ts @@ -0,0 +1,7 @@ +import { defineFunction } from '@aws-amplify/backend'; + +export const customerS3Import = defineFunction({ + name: 'customer-s3-import', + entry: './handler.ts', + timeoutSeconds: 30, +}); diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/handler.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/handler.ts new file mode 100644 index 00000000000..0383ebf092d --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/handler.ts @@ -0,0 +1,20 @@ +import type { Handler } from 'aws-lambda'; +import { Amplify } from 'aws-amplify'; +import { generateClient } from 'aws-amplify/data'; +import type { Schema } from '../../data/resource.js'; +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime'; +// @ts-ignore +import { env } from '$amplify/env/todo-count.js'; + +const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig( + env +); + +Amplify.configure(resourceConfig, libraryOptions); + +const client = generateClient(); + +export const handler: Handler = async () => { + const todos = await client.models.Todo.list(); + return todos.data.length; +}; diff --git a/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/resource.ts b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/resource.ts new file mode 100644 index 00000000000..6fa45f4b81f --- /dev/null +++ b/packages/integration-tests/src/test-projects/data_access_from_function/amplify/functions/todo-count/resource.ts @@ -0,0 +1,7 @@ +import { defineFunction } from '@aws-amplify/backend'; + +export const todoCount = defineFunction({ + name: 'todo-count', + entry: './handler.ts', + timeoutSeconds: 30, +});