diff --git a/.changeset/sharp-penguins-impress.md b/.changeset/sharp-penguins-impress.md new file mode 100644 index 0000000000..f2cc27815f --- /dev/null +++ b/.changeset/sharp-penguins-impress.md @@ -0,0 +1,11 @@ +--- +'@aws-amplify/deployed-backend-client': minor +'@aws-amplify/backend-output-schemas': minor +'@aws-amplify/backend-output-storage': minor +'@aws-amplify/backend-storage': minor +'@aws-amplify/client-config': minor +'@aws-amplify/plugin-types': minor +'@aws-amplify/backend': minor +--- + +support adding more than one bucket diff --git a/package-lock.json b/package-lock.json index a02a2c1999..8045c398a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16811,6 +16811,15 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==" }, + "node_modules/@types/lodash.ismatch": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@types/lodash.ismatch/-/lodash.ismatch-4.4.9.tgz", + "integrity": "sha512-qWihnStOPKH8urljLGm6ZOEdN/5Bt4vxKR81tL3L4ArUNLvcf9RW3QSnPs21eix5BiqioSWq4aAXD4Iep+d0fw==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lodash.mergewith": { "version": "4.6.9", "resolved": "https://registry.npmjs.org/@types/lodash.mergewith/-/lodash.mergewith-4.6.9.tgz", @@ -24692,6 +24701,12 @@ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "dev": true }, + "node_modules/lodash.ismatch": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", + "dev": true + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -29777,7 +29792,7 @@ }, "packages/backend-auth": { "name": "@aws-amplify/backend-auth", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "dependencies": { "@aws-amplify/auth-construct": "^1.1.5", @@ -29786,7 +29801,7 @@ }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.3", - "@aws-amplify/platform-core": "^1.0.0" + "@aws-amplify/platform-core": "^1.0.4" }, "peerDependencies": { "aws-cdk-lib": "^2.132.0", @@ -29816,10 +29831,10 @@ }, "packages/backend-deployer": { "name": "@aws-amplify/backend-deployer", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/platform-core": "^1.0.3", + "@aws-amplify/platform-core": "^1.0.4", "@aws-amplify/plugin-types": "^1.1.0", "execa": "^8.0.1", "tsx": "^4.6.1" @@ -29929,18 +29944,18 @@ }, "packages/cli": { "name": "@aws-amplify/backend-cli", - "version": "1.2.1", + "version": "1.2.2", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/backend-deployer": "^1.0.2", + "@aws-amplify/backend-deployer": "^1.0.3", "@aws-amplify/backend-output-schemas": "^1.1.0", "@aws-amplify/backend-secret": "^1.0.0", "@aws-amplify/cli-core": "^1.1.1", - "@aws-amplify/client-config": "^1.1.1", - "@aws-amplify/deployed-backend-client": "^1.1.0", + "@aws-amplify/client-config": "^1.1.2", + "@aws-amplify/deployed-backend-client": "^1.2.0", "@aws-amplify/form-generator": "^1.0.0", - "@aws-amplify/model-generator": "^1.0.2", - "@aws-amplify/platform-core": "^1.0.3", + "@aws-amplify/model-generator": "^1.0.3", + "@aws-amplify/platform-core": "^1.0.4", "@aws-amplify/sandbox": "^1.1.1", "@aws-amplify/schema-generator": "^1.1.0", "@aws-sdk/client-amplify": "^3.465.0", @@ -30074,13 +30089,13 @@ }, "packages/client-config": { "name": "@aws-amplify/client-config", - "version": "1.1.1", + "version": "1.1.2", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.1.0", - "@aws-amplify/deployed-backend-client": "^1.1.0", - "@aws-amplify/model-generator": "^1.0.2", - "@aws-amplify/platform-core": "^1.0.3", + "@aws-amplify/deployed-backend-client": "^1.2.0", + "@aws-amplify/model-generator": "^1.0.3", + "@aws-amplify/platform-core": "^1.0.4", "zod": "^3.22.2" }, "devDependencies": { @@ -30202,11 +30217,11 @@ }, "packages/deployed-backend-client": { "name": "@aws-amplify/deployed-backend-client", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.1.0", - "@aws-amplify/platform-core": "^1.0.3", + "@aws-amplify/platform-core": "^1.0.4", "zod": "^3.22.2" }, "peerDependencies": { @@ -30273,6 +30288,7 @@ "@aws-sdk/client-sts": "^3.465.0", "@aws-sdk/credential-providers": "^3.465.0", "@smithy/shared-ini-file-loader": "^2.2.5", + "@types/lodash.ismatch": "^4.4.9", "aws-amplify": "^6.0.16", "aws-appsync-auth-link": "^3.0.7", "aws-cdk-lib": "^2.132.0", @@ -30281,6 +30297,7 @@ "fs-extra": "^11.1.1", "glob": "^10.2.7", "graphql-tag": "^2.12.6", + "lodash.ismatch": "^4.4.0", "node-fetch": "^3.3.2", "semver": "^7.5.4", "ssh2": "^1.15.0", @@ -30302,14 +30319,14 @@ }, "packages/model-generator": { "name": "@aws-amplify/model-generator", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.1.0", - "@aws-amplify/deployed-backend-client": "^1.1.0", + "@aws-amplify/deployed-backend-client": "^1.2.0", "@aws-amplify/graphql-generator": "^0.4.0", "@aws-amplify/graphql-types-generator": "^3.6.0", - "@aws-amplify/platform-core": "^1.0.3", + "@aws-amplify/platform-core": "^1.0.4", "@aws-sdk/client-appsync": "^3.465.0", "@aws-sdk/client-s3": "^3.465.0", "@aws-sdk/credential-providers": "^3.465.0", @@ -30323,7 +30340,7 @@ }, "packages/platform-core": { "name": "@aws-amplify/platform-core", - "version": "1.0.3", + "version": "1.0.4", "license": "Apache-2.0", "dependencies": { "@aws-amplify/plugin-types": "^1.1.0", diff --git a/packages/backend-output-schemas/API.md b/packages/backend-output-schemas/API.md index 6c2692bc54..41fbd8abed 100644 --- a/packages/backend-output-schemas/API.md +++ b/packages/backend-output-schemas/API.md @@ -275,24 +275,29 @@ export const unifiedBackendOutputSchema: z.ZodObject<{ payload: z.ZodObject<{ bucketName: z.ZodString; storageRegion: z.ZodString; + buckets: z.ZodOptional; }, "strip", z.ZodTypeAny, { bucketName: string; storageRegion: string; + buckets?: string | undefined; }, { bucketName: string; storageRegion: string; + buckets?: string | undefined; }>; }, "strip", z.ZodTypeAny, { version: "1"; payload: { bucketName: string; storageRegion: string; + buckets?: string | undefined; }; }, { version: "1"; payload: { bucketName: string; storageRegion: string; + buckets?: string | undefined; }; }>]>>; "AWS::Amplify::Custom": z.ZodOptional; }, "strip", z.ZodTypeAny, { bucketName: string; storageRegion: string; + buckets?: string | undefined; }, { bucketName: string; storageRegion: string; + buckets?: string | undefined; }>; }, "strip", z.ZodTypeAny, { version: "1"; payload: { bucketName: string; storageRegion: string; + buckets?: string | undefined; }; }, { version: "1"; payload: { bucketName: string; storageRegion: string; + buckets?: string | undefined; }; }>]>; diff --git a/packages/backend-output-schemas/src/storage/v1.ts b/packages/backend-output-schemas/src/storage/v1.ts index 31407cd478..5095714a81 100644 --- a/packages/backend-output-schemas/src/storage/v1.ts +++ b/packages/backend-output-schemas/src/storage/v1.ts @@ -1,9 +1,16 @@ import { z } from 'zod'; +const bucketSchema = z.object({ + name: z.string(), + bucketName: z.string(), + storageRegion: z.string(), +}); + export const storageOutputSchema = z.object({ version: z.literal('1'), payload: z.object({ bucketName: z.string(), storageRegion: z.string(), + buckets: z.string(z.array(bucketSchema)).optional(), // JSON serialized array of bucketSchema }), }); diff --git a/packages/backend-output-storage/API.md b/packages/backend-output-storage/API.md index c79ecc0472..b4924f8d62 100644 --- a/packages/backend-output-storage/API.md +++ b/packages/backend-output-storage/API.md @@ -8,6 +8,7 @@ import { BackendOutputEntry } from '@aws-amplify/plugin-types'; import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types'; +import { DeepPartial } from '@aws-amplify/plugin-types'; import * as _os from 'os'; import { PackageJsonReader } from '@aws-amplify/platform-core'; import { Stack } from 'aws-cdk-lib'; @@ -37,7 +38,7 @@ export type Platform = 'Mac' | 'Windows' | 'Linux' | 'Other'; export class StackMetadataBackendOutputStorageStrategy implements BackendOutputStorageStrategy { constructor(stack: Stack); addBackendOutputEntry: (keyName: string, backendOutputEntry: BackendOutputEntry) => void; - appendToBackendOutputList: (keyName: string, backendOutputEntry: BackendOutputEntry) => void; + appendToBackendOutputList: (keyName: string, backendOutputEntry: DeepPartial) => void; } // (No @packageDocumentation comment for this package) diff --git a/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.test.ts b/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.test.ts index 7cfce6b0c2..9b299be238 100644 --- a/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.test.ts +++ b/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.test.ts @@ -16,17 +16,18 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.addBackendOutputEntry('TestStorageOutput', { version: '1', payload: { - something: 'special', + bucketName: 'test-bucket', + storageRegion: 'us-west-2', }, }); const template = Template.fromStack(stack); - template.hasOutput('something', { Value: 'special' }); + template.hasOutput('bucketName', { Value: 'test-bucket' }); template.templateMatches({ Metadata: { TestStorageOutput: { version: '1', - stackOutputs: ['something'], + stackOutputs: ['bucketName', 'storageRegion'], }, }, }); @@ -41,7 +42,8 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.addBackendOutputEntry('TestStorageOutput', { version: '44', payload: { - something: 'special', + bucketName: 'test-bucket', + storageRegion: 'us-west-2', }, }); @@ -61,17 +63,19 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: 'special', + buckets: 'test-bucket', }, }); const template = Template.fromStack(stack); - template.hasOutput('something', { Value: JSON.stringify(['special']) }); + template.hasOutput('buckets', { + Value: JSON.stringify(['test-bucket']), + }); template.templateMatches({ Metadata: { TestStorageOutput: { version: '1', - stackOutputs: ['something'], + stackOutputs: ['buckets'], }, }, }); @@ -86,57 +90,93 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: 'special', + buckets: JSON.stringify({ + name: 'test-bucket', + bucketName: 'test-bucket', + storageRegion: 'us-west-2', + }), + }, + }); + outputStorage.addBackendOutputEntry('TestStorageOutput', { + version: '1', + payload: { + bucketName: 'test-bucket-two', + storageRegion: 'us-west-2', }, }); outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: 'otherSpecial', + buckets: JSON.stringify({ + name: 'test-bucket-two', + bucketName: 'test-bucket-two', + storageRegion: 'us-west-2', + }), }, }); const template = Template.fromStack(stack); - template.hasOutput('something', { - Value: JSON.stringify(['special', 'otherSpecial']), + template.hasOutput('buckets', { + Value: JSON.stringify([ + '{"name":"test-bucket","bucketName":"test-bucket","storageRegion":"us-west-2"}', + '{"name":"test-bucket-two","bucketName":"test-bucket-two","storageRegion":"us-west-2"}', + ]), }); template.templateMatches({ Metadata: { TestStorageOutput: { version: '1', - stackOutputs: ['something'], + stackOutputs: ['buckets', 'bucketName', 'storageRegion'], }, }, }); }); - void it('appends a cdk token to an existing list in stack output', () => { + void it('appends a cdk token to an existing list in stack output with two buckets', () => { const testToken = Token.asString('testToken'); const app = new App(); const stack = new Stack(app); const outputStorage = new StackMetadataBackendOutputStorageStrategy( stack ); + outputStorage.addBackendOutputEntry('TestStorageOutput', { + version: '1', + payload: { + bucketName: testToken, + storageRegion: 'us-west-2', + }, + }); outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: 'special', + buckets: JSON.stringify({ + name: testToken, + bucketName: testToken, + storageRegion: 'us-west-2', + }), }, }); outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: testToken, + buckets: JSON.stringify({ + name: 'test-bucket-two', + bucketName: 'test-bucket-two', + storageRegion: 'us-west-2', + }), }, }); const template = Template.fromStack(stack); - template.hasOutput('something', { - Value: JSON.stringify(['special', 'testToken']), + template.hasOutput('buckets', { + Value: JSON.stringify([ + '{"name":"testToken","bucketName":"testToken","storageRegion":"us-west-2"}', + '{"name":"test-bucket-two","bucketName":"test-bucket-two","storageRegion":"us-west-2"}', + ]), }); template.templateMatches({ Metadata: { TestStorageOutput: { version: '1', - stackOutputs: ['something'], + stackOutputs: ['buckets', 'bucketName', 'storageRegion'], }, }, }); @@ -151,7 +191,7 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '1', payload: { - something: 'special', + buckets: 'test-bucket', }, }); @@ -160,7 +200,8 @@ void describe('StackMetadataBackendOutputStorageStrategy', () => { outputStorage.appendToBackendOutputList('TestStorageOutput', { version: '2', payload: { - something: 'otherSpecial', + bucketName: 'test-bucket', + storageRegion: 'us-west-2', }, }), { diff --git a/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.ts b/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.ts index 494504bc59..e5bc26e3bc 100644 --- a/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.ts +++ b/packages/backend-output-storage/src/stack_metadata_output_storage_strategy.ts @@ -1,6 +1,7 @@ import { BackendOutputEntry, BackendOutputStorageStrategy, + DeepPartial, } from '@aws-amplify/plugin-types'; import { CfnOutput, Lazy, Stack } from 'aws-cdk-lib'; @@ -36,10 +37,14 @@ export class StackMetadataBackendOutputStorageStrategy new CfnOutput(this.stack, key, { value }); }); - this.stack.addMetadata(keyName, { - version: backendOutputEntry.version, - stackOutputs: Object.keys(backendOutputEntry.payload), - }); + const metadata = this.stack.templateOptions.metadata || {}; + const existingMetadataEntry = metadata[keyName]; + + this.addOrUpdateMetadata( + existingMetadataEntry, + keyName, + backendOutputEntry + ); }; /** @@ -47,7 +52,7 @@ export class StackMetadataBackendOutputStorageStrategy */ appendToBackendOutputList = ( keyName: string, - backendOutputEntry: BackendOutputEntry + backendOutputEntry: DeepPartial ): void => { const version = backendOutputEntry.version; let listsMap = this.lazyListValueMap.get(keyName); @@ -61,32 +66,67 @@ export class StackMetadataBackendOutputStorageStrategy `Metadata entry for ${keyName} at version ${existingMetadataEntry.version} already exists. Cannot add another entry for the same key at version ${version}.` ); } - } else { - this.stack.addMetadata(keyName, { - version, - stackOutputs: Lazy.list({ - produce: () => Array.from(listsMap ? listsMap.keys() : []), - }), - }); } - Object.entries(backendOutputEntry.payload).forEach(([listName, value]) => { - if (!listsMap) { - listsMap = new Map(); - this.lazyListValueMap.set(keyName, listsMap); - } - let outputList = listsMap.get(listName); + this.addOrUpdateMetadata( + existingMetadataEntry, + keyName, + backendOutputEntry as BackendOutputEntry + ); - if (outputList) { - outputList.push(value); - } else { - outputList = [value]; - listsMap.set(listName, outputList); + Object.entries(backendOutputEntry.payload ?? []).forEach( + ([listName, value]) => { + if (!value) { + return; + } + if (!listsMap) { + listsMap = new Map(); + this.lazyListValueMap.set(keyName, listsMap); + } + let outputList = listsMap.get(listName); - new CfnOutput(this.stack, listName, { - value: Lazy.string({ produce: () => JSON.stringify(outputList) }), - }); + if (outputList) { + outputList.push(value); + } else { + outputList = [value]; + listsMap.set(listName, outputList); + + new CfnOutput(this.stack, listName, { + value: Lazy.string({ produce: () => JSON.stringify(outputList) }), + }); + } } - }); + ); }; + + /** + * Add or update metadata entry. + * @param existingMetadataEntry - The existing metadata entry. + * @param keyName - The key name. + * @param backendOutputEntry - The backend output entry. + */ + private addOrUpdateMetadata( + existingMetadataEntry: + | { version: string; stackOutputs: string[] } + | undefined, + keyName: string, + backendOutputEntry: BackendOutputEntry + ) { + if (existingMetadataEntry) { + this.stack.addMetadata(keyName, { + version: backendOutputEntry.version, + stackOutputs: [ + ...new Set([ + ...Object.keys(backendOutputEntry.payload), + ...existingMetadataEntry.stackOutputs, + ]), + ], + }); + } else { + this.stack.addMetadata(keyName, { + version: backendOutputEntry.version, + stackOutputs: Object.keys(backendOutputEntry.payload), + }); + } + } } diff --git a/packages/backend-storage/API.md b/packages/backend-storage/API.md index 5baf581857..e7472b5770 100644 --- a/packages/backend-storage/API.md +++ b/packages/backend-storage/API.md @@ -23,6 +23,7 @@ export type AmplifyStorageFactoryProps = Omit; diff --git a/packages/backend-storage/src/construct.test.ts b/packages/backend-storage/src/construct.test.ts index 158c1e2af0..9621ea8e4f 100644 --- a/packages/backend-storage/src/construct.test.ts +++ b/packages/backend-storage/src/construct.test.ts @@ -1,14 +1,8 @@ -import { describe, it, mock } from 'node:test'; +import { describe, it } from 'node:test'; import { AmplifyStorage } from './construct.js'; import { App, Stack } from 'aws-cdk-lib'; import { Capture, Template } from 'aws-cdk-lib/assertions'; -import { - BackendOutputEntry, - BackendOutputStorageStrategy, -} from '@aws-amplify/plugin-types'; import assert from 'node:assert'; -import { Bucket } from 'aws-cdk-lib/aws-s3'; -import { storageOutputKey } from '@aws-amplify/backend-output-schemas'; void describe('AmplifyStorage', () => { void it('creates a bucket', () => { @@ -107,59 +101,6 @@ void describe('AmplifyStorage', () => { ); }); - void describe('storeOutput', () => { - void it('stores output using the provided strategy', () => { - const app = new App(); - const stack = new Stack(app); - - const storeOutputMock = mock.fn(); - const storageStrategy: BackendOutputStorageStrategy = - { - addBackendOutputEntry: storeOutputMock, - appendToBackendOutputList: storeOutputMock, - }; - - const storageConstruct = new AmplifyStorage(stack, 'test', { - name: 'testName', - outputStorageStrategy: storageStrategy, - }); - - const expectedBucketName = ( - storageConstruct.node.findChild('Bucket') as Bucket - ).bucketName; - const expectedRegion = Stack.of(storageConstruct).region; - - const storeOutputArgs = storeOutputMock.mock.calls[0].arguments; - assert.strictEqual(storeOutputArgs.length, 2); - - assert.deepStrictEqual(storeOutputArgs, [ - storageOutputKey, - { - version: '1', - payload: { - bucketName: expectedBucketName, - storageRegion: expectedRegion, - }, - }, - ]); - }); - void it('stores output when no storage strategy is injected', () => { - const app = new App(); - const stack = new Stack(app); - - new AmplifyStorage(stack, 'test', { name: 'testName' }); - const template = Template.fromStack(stack); - template.templateMatches({ - Metadata: { - [storageOutputKey]: { - version: '1', - stackOutputs: ['storageRegion', 'bucketName'], - }, - }, - }); - }); - }); - void describe('storage overrides', () => { void it('can override bucket properties', () => { const app = new App(); diff --git a/packages/backend-storage/src/construct.ts b/packages/backend-storage/src/construct.ts index ad96b56a30..1cbdb71670 100644 --- a/packages/backend-storage/src/construct.ts +++ b/packages/backend-storage/src/construct.ts @@ -13,15 +13,9 @@ import { FunctionResources, ResourceProvider, } from '@aws-amplify/plugin-types'; -import { - StorageOutput, - storageOutputKey, -} from '@aws-amplify/backend-output-schemas'; +import { StorageOutput } from '@aws-amplify/backend-output-schemas'; import { RemovalPolicy, Stack } from 'aws-cdk-lib'; -import { - AttributionMetadataStorage, - StackMetadataBackendOutputStorageStrategy, -} from '@aws-amplify/backend-output-storage'; +import { AttributionMetadataStorage } from '@aws-amplify/backend-output-storage'; import { fileURLToPath } from 'node:url'; import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { S3EventSourceV2 } from 'aws-cdk-lib/aws-lambda-event-sources'; @@ -32,6 +26,12 @@ const storageStackType = 'storage-S3'; export type AmplifyStorageTriggerEvent = 'onDelete' | 'onUpload'; export type AmplifyStorageProps = { + /** + * Whether this storage resource is the default storage resource for the backend. + * required and relevant only if there are multiple storage resources defined. + * @default false. + */ + isDefault?: boolean; /** * Friendly name that will be used to derive the S3 Bucket name */ @@ -80,11 +80,15 @@ export class AmplifyStorage implements ResourceProvider { readonly resources: StorageResources; + readonly isDefault: boolean; + readonly name: string; /** * Create a new AmplifyStorage instance */ constructor(scope: Construct, id: string, props: AmplifyStorageProps) { super(scope, id); + this.isDefault = props.isDefault || false; + this.name = props.name; const bucketProps: BucketProps = { versioned: props.versioned || false, @@ -122,8 +126,6 @@ export class AmplifyStorage }, }; - this.storeOutput(props.outputStorageStrategy); - new AttributionMetadataStorage().storeAttributionMetadata( Stack.of(this), storageStackType, @@ -141,21 +143,4 @@ export class AmplifyStorage new S3EventSourceV2(this.resources.bucket, { events }) ); }; - - /** - * Store storage outputs using provided strategy - */ - private storeOutput = ( - outputStorageStrategy: BackendOutputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( - Stack.of(this) - ) - ): void => { - outputStorageStrategy.addBackendOutputEntry(storageOutputKey, { - version: '1', - payload: { - storageRegion: Stack.of(this).region, - bucketName: this.resources.bucket.bucketName, - }, - }); - }; } diff --git a/packages/backend-storage/src/factory.test.ts b/packages/backend-storage/src/factory.test.ts index 2feff4e5a2..d3f425a6d2 100644 --- a/packages/backend-storage/src/factory.test.ts +++ b/packages/backend-storage/src/factory.test.ts @@ -21,6 +21,7 @@ import { StackResolverStub, } from '@aws-amplify/backend-platform-test-stubs'; import { StorageResources } from './construct.js'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; const createStackAndSetContext = (): Stack => { const app = new App(); @@ -31,15 +32,16 @@ const createStackAndSetContext = (): Stack => { return stack; }; -void describe('AmplifyStorageFactory', () => { - let storageFactory: ConstructFactory>; - let constructContainer: ConstructContainer; - let outputStorageStrategy: BackendOutputStorageStrategy; - let importPathVerifier: ImportPathVerifier; - let resourceNameValidator: ResourceNameValidator; +let storageFactory: ConstructFactory>; +let storageFactory2: ConstructFactory>; +let constructContainer: ConstructContainer; +let outputStorageStrategy: BackendOutputStorageStrategy; +let importPathVerifier: ImportPathVerifier; +let resourceNameValidator: ResourceNameValidator; - let getInstanceProps: ConstructFactoryGetInstanceProps; +let getInstanceProps: ConstructFactoryGetInstanceProps; +void describe('AmplifyStorageFactory', () => { beforeEach(() => { storageFactory = defineStorage({ name: 'testName' }); const stack = createStackAndSetContext(); @@ -80,23 +82,6 @@ void describe('AmplifyStorageFactory', () => { template.resourceCountIs('AWS::S3::Bucket', 1); }); - void it('sets output in storage strategy', () => { - const storeOutputMock = mock.fn(); - - const outputStorageStrategy: BackendOutputStorageStrategy = - { - addBackendOutputEntry: storeOutputMock, - appendToBackendOutputList: storeOutputMock, - }; - - storageFactory.getInstance({ - ...getInstanceProps, - outputStorageStrategy, - }); - - assert.strictEqual(storeOutputMock.mock.callCount(), 1); - }); - void it('verifies constructor import path', () => { const importPathVerifier = { verify: mock.fn(), @@ -138,3 +123,68 @@ void describe('AmplifyStorageFactory', () => { ); }); }); + +void describe('AmplifyStorageFactory', () => { + let stack: Stack; + beforeEach(() => { + stack = createStackAndSetContext(); + + constructContainer = new ConstructContainerStub( + new StackResolverStub(stack) + ); + + outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( + stack + ); + + importPathVerifier = new ImportPathVerifierStub(); + + resourceNameValidator = new ResourceNameValidatorStub(); + + getInstanceProps = { + constructContainer, + outputStorageStrategy, + importPathVerifier, + resourceNameValidator, + }; + }); + + void it('if more than one default bucket, throw', () => { + storageFactory = defineStorage({ name: 'testName', isDefault: true }); + storageFactory2 = defineStorage({ name: 'testName2', isDefault: true }); + storageFactory.getInstance(getInstanceProps); + storageFactory2.getInstance(getInstanceProps); + + assert.throws( + () => Template.fromStack(stack), + new AmplifyUserError('MultipleDefaultStorageError', { + message: 'More than one default storage set in the Amplify project.', + resolution: + 'Remove `isDefault: true` from all `defineStorage` calls except for one in your Amplify project.', + }) + ); + }); + + void it('if there is no default storage among storage, throw', () => { + storageFactory = defineStorage({ name: 'testName' }); + storageFactory2 = defineStorage({ name: 'testName2' }); + storageFactory.getInstance(getInstanceProps); + storageFactory2.getInstance(getInstanceProps); + + assert.throws( + () => Template.fromStack(stack), + new AmplifyUserError('NoDefaultStorageError', { + message: 'No default storage set in the Amplify project.', + resolution: + 'Add `isDefault: true` to one of the `defineStorage` calls in your Amplify project.', + }) + ); + }); + + void it('if there is no default storage for one storage, ok', () => { + storageFactory = defineStorage({ name: 'testName' }); + storageFactory.getInstance(getInstanceProps); + + assert.ok(Template.fromStack(stack)); + }); +}); diff --git a/packages/backend-storage/src/factory.ts b/packages/backend-storage/src/factory.ts index 743f6efb39..49eff9ba1e 100644 --- a/packages/backend-storage/src/factory.ts +++ b/packages/backend-storage/src/factory.ts @@ -8,11 +8,13 @@ import * as path from 'path'; import { AmplifyStorage, StorageResources } from './construct.js'; import { AmplifyStorageFactoryProps } from './types.js'; import { StorageContainerEntryGenerator } from './storage_container_entry_generator.js'; +import { Aspects, Stack } from 'aws-cdk-lib'; +import { StorageOutputsAspect } from './storage_outputs_aspect.js'; /** * Singleton factory for a Storage bucket that can be used in `resource.ts` files */ -class AmplifyStorageFactory +export class AmplifyStorageFactory implements ConstructFactory> { private generator: ConstructContainerEntryGenerator; @@ -46,7 +48,23 @@ class AmplifyStorageFactory getInstanceProps ); } - return constructContainer.getOrCompute(this.generator) as AmplifyStorage; + const amplifyStorage = constructContainer.getOrCompute( + this.generator + ) as AmplifyStorage; + + /* + * only call Aspects once, + * otherwise there will be the an error - + * "there is already a construct with name 'storageRegion'" + */ + const aspects = Aspects.of(Stack.of(amplifyStorage)); + if (!aspects.all.length) { + aspects.add( + new StorageOutputsAspect(getInstanceProps.outputStorageStrategy) + ); + } + + return amplifyStorage; }; } diff --git a/packages/backend-storage/src/index.ts b/packages/backend-storage/src/index.ts index 9fdcd83804..ad1007e9ec 100644 --- a/packages/backend-storage/src/index.ts +++ b/packages/backend-storage/src/index.ts @@ -1,4 +1,4 @@ -export * from './factory.js'; +export { defineStorage } from './factory.js'; export { StorageResources, AmplifyStorageProps, diff --git a/packages/backend-storage/src/storage_access_policy_factory.ts b/packages/backend-storage/src/storage_access_policy_factory.ts index ca3c803123..c7408a5df7 100644 --- a/packages/backend-storage/src/storage_access_policy_factory.ts +++ b/packages/backend-storage/src/storage_access_policy_factory.ts @@ -17,11 +17,8 @@ export type Permission = { * Generates IAM policies scoped to a single bucket */ export class StorageAccessPolicyFactory { - private readonly namePrefix = 'storageAccess'; private readonly stack: Stack; - private policyCount = 1; - /** * Instantiate with the bucket to generate policies for */ @@ -63,9 +60,13 @@ export class StorageAccessPolicyFactory { }); } - return new Policy(this.stack, `${this.namePrefix}${this.policyCount++}`, { - statements, - }); + return new Policy( + this.stack, + `storageAccess${this.stack.node.children.length}`, + { + statements, + } + ); }; private getStatement = ( diff --git a/packages/backend-storage/src/storage_outputs_aspect.test.ts b/packages/backend-storage/src/storage_outputs_aspect.test.ts new file mode 100644 index 0000000000..7487f0864d --- /dev/null +++ b/packages/backend-storage/src/storage_outputs_aspect.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { StorageOutputsAspect } from './storage_outputs_aspect.js'; +import { AmplifyStorage } from './construct.js'; +import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types'; +import { StorageOutput } from '@aws-amplify/backend-output-schemas'; +import { App, Stack } from 'aws-cdk-lib'; +import { IConstruct } from 'constructs'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +void describe('StorageOutputsAspect', () => { + let app: App; + let stack: Stack; + let outputStorageStrategy: BackendOutputStorageStrategy; + let aspect: StorageOutputsAspect; + const addBackendOutputEntryMock = mock.fn(); + const appendToBackendOutputListMock = mock.fn(); + + beforeEach(() => { + app = new App(); + stack = new Stack(app); + outputStorageStrategy = { + addBackendOutputEntry: addBackendOutputEntryMock, + appendToBackendOutputList: appendToBackendOutputListMock, + }; + }); + + afterEach(() => { + addBackendOutputEntryMock.mock.resetCalls(); + appendToBackendOutputListMock.mock.resetCalls(); + }); + + void describe('visit', () => { + void it('should store the storage outputs if the node is an AmplifyStorage construct', () => { + const node = new AmplifyStorage(stack, 'test', { name: 'testName' }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + aspect.visit(node); + + assert.equal(addBackendOutputEntryMock.mock.callCount(), 1); + assert.equal(appendToBackendOutputListMock.mock.callCount(), 1); + }); + + void it('should not store the storage outputs if the node is not an AmplifyStorage construct', () => { + const node: IConstruct = {} as IConstruct; + + aspect.visit(node); + + assert.equal(addBackendOutputEntryMock.mock.callCount(), 0); + assert.equal(appendToBackendOutputListMock.mock.callCount(), 0); + }); + + void it('should only traverse the siblings once to store the outputs', () => { + const node = new AmplifyStorage(stack, 'test', { name: 'testName' }); + new AmplifyStorage(stack, 'test2', { + name: 'testName2', + isDefault: true, + }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + + aspect.visit(node); + + assert.equal(addBackendOutputEntryMock.mock.callCount(), 1); + assert.equal(appendToBackendOutputListMock.mock.callCount(), 2); + }); + }); + + void describe('storeOutput', () => { + void it('should store the output if the storage is default', () => { + const node = new AmplifyStorage(stack, 'test', { + name: 'testName', + isDefault: true, + }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + aspect.visit(node); + + assert.equal( + addBackendOutputEntryMock.mock.calls[0].arguments[0], + 'AWS::Amplify::Storage' + ); + assert.equal( + addBackendOutputEntryMock.mock.calls[0].arguments[1].payload + .storageRegion, + Stack.of(node).region + ); + assert.equal( + appendToBackendOutputListMock.mock.calls[0].arguments[0], + 'AWS::Amplify::Storage' + ); + assert.equal( + appendToBackendOutputListMock.mock.calls[0].arguments[1].payload + .buckets, + JSON.stringify({ + name: node.name, + bucketName: node.resources.bucket.bucketName, + storageRegion: Stack.of(node).region, + }) + ); + }); + + void it('should store the output if the storage is non-default and it is the only bucket', () => { + const node = new AmplifyStorage(stack, 'test', { name: 'testName' }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + aspect.visit(node); + + assert.equal( + addBackendOutputEntryMock.mock.calls[0].arguments[0], + 'AWS::Amplify::Storage' + ); + assert.equal( + addBackendOutputEntryMock.mock.calls[0].arguments[1].payload + .storageRegion, + Stack.of(node).region + ); + assert.equal( + addBackendOutputEntryMock.mock.calls[0].arguments[1].payload.bucketName, + node.resources.bucket.bucketName + ); + assert.equal( + appendToBackendOutputListMock.mock.calls[0].arguments[0], + 'AWS::Amplify::Storage' + ); + assert.equal( + appendToBackendOutputListMock.mock.calls[0].arguments[1].payload + .buckets, + JSON.stringify({ + name: node.name, + bucketName: node.resources.bucket.bucketName, + storageRegion: Stack.of(node).region, + }) + ); + }); + }); + + void describe('Validate', () => { + void it('should throw if there is no default bucket', () => { + const node = new AmplifyStorage(stack, 'test', { name: 'testName' }); + new AmplifyStorage(stack, 'test2', { name: 'testName2' }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + + assert.throws( + () => { + aspect.visit(node); + }, + (err: AmplifyUserError) => { + assert.equal(err.name, 'NoDefaultStorageError'); + assert.equal( + err.message, + 'No default storage set in the Amplify project.' + ); + return true; + } + ); + }); + + void it('should throw if there is more than one default bucket', () => { + const node = new AmplifyStorage(stack, 'test', { + name: 'testName', + isDefault: true, + }); + new AmplifyStorage(stack, 'test2', { + name: 'testName2', + isDefault: true, + }); + aspect = new StorageOutputsAspect(outputStorageStrategy); + + assert.throws( + () => { + aspect.visit(node); + }, + (err: AmplifyUserError) => { + assert.equal(err.name, 'MultipleDefaultStorageError'); + assert.equal( + err.message, + 'More than one default storage set in the Amplify project.' + ); + return true; + } + ); + }); + }); +}); diff --git a/packages/backend-storage/src/storage_outputs_aspect.ts b/packages/backend-storage/src/storage_outputs_aspect.ts new file mode 100644 index 0000000000..9e9a9fcfa0 --- /dev/null +++ b/packages/backend-storage/src/storage_outputs_aspect.ts @@ -0,0 +1,111 @@ +import { + StorageOutput, + storageOutputKey, +} from '@aws-amplify/backend-output-schemas'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types'; +import { IAspect, Stack } from 'aws-cdk-lib'; +import { IConstruct } from 'constructs'; +import { AmplifyStorage } from './construct.js'; + +/** + * Aspect to store the storage outputs in the backend + */ +export class StorageOutputsAspect implements IAspect { + isStorageProcessed = false; + outputStorageStrategy; + /** + * Constructs a new instance of the StorageValidator class. + */ + constructor( + outputStorageStrategy: BackendOutputStorageStrategy + ) { + this.outputStorageStrategy = outputStorageStrategy; + } + + /** + * Visit the given node. + * If the node is an AmplifyStorage construct, we will traverse its siblings in the same stack + * @param node The node to visit. + */ + public visit(node: IConstruct): void { + if (!(node instanceof AmplifyStorage) || this.isStorageProcessed) { + return; + } + /** + * only traverse the siblings once to store the outputs, + * storing the same outputs multiple times result in error + */ + this.isStorageProcessed = true; + + const storageInstances = Stack.of(node).node.children.filter( + (el) => el instanceof AmplifyStorage + ); + const storageCount = storageInstances.length; + + let defaultStorageFound = false; + + Stack.of(node).node.children.forEach((child) => { + if (!(child instanceof AmplifyStorage)) { + return; + } + if (child.isDefault && !defaultStorageFound) { + defaultStorageFound = true; + } else if (child.isDefault && defaultStorageFound) { + throw new AmplifyUserError('MultipleDefaultStorageError', { + message: `More than one default storage set in the Amplify project.`, + resolution: + 'Remove `isDefault: true` from all `defineStorage` calls except for one in your Amplify project.', + }); + } + }); + /* + * If there is no default bucket set and there is only one bucket, + * we need to set the bucket as default. + */ + if (!defaultStorageFound && storageCount === 1) { + this.storeOutput(this.outputStorageStrategy, true, node); + } else if (!defaultStorageFound && storageCount > 1) { + throw new AmplifyUserError('NoDefaultStorageError', { + message: 'No default storage set in the Amplify project.', + resolution: + 'Add `isDefault: true` to one of the `defineStorage` calls in your Amplify project.', + }); + } else { + Stack.of(node).node.children.forEach((child) => { + if (!(child instanceof AmplifyStorage)) { + return; + } + this.storeOutput(this.outputStorageStrategy, child.isDefault, child); + }); + } + } + + private storeOutput = ( + outputStorageStrategy: BackendOutputStorageStrategy, + isDefault: boolean = false, + node: AmplifyStorage + ): void => { + if (isDefault) { + outputStorageStrategy.addBackendOutputEntry(storageOutputKey, { + version: '1', + payload: { + storageRegion: Stack.of(node).region, + bucketName: node.resources.bucket.bucketName, + }, + }); + } + + // both default and non-default buckets should have the name, bucket name, and storage region stored in `buckets` field + outputStorageStrategy.appendToBackendOutputList(storageOutputKey, { + version: '1', + payload: { + buckets: JSON.stringify({ + name: node.name, + bucketName: node.resources.bucket.bucketName, + storageRegion: Stack.of(node).region, + }), + }, + }); + }; +} diff --git a/packages/client-config/API.md b/packages/client-config/API.md index aa690ed2fd..67eefb0f69 100644 --- a/packages/client-config/API.md +++ b/packages/client-config/API.md @@ -22,6 +22,16 @@ interface AmazonLocationServiceConfig { // @public type AmazonPinpointChannels = 'IN_APP_MESSAGING' | 'FCM' | 'APNS' | 'EMAIL' | 'SMS'; +// @public (undocumented) +interface AmplifyStorageBucket { + // (undocumented) + aws_region: string; + // (undocumented) + bucket_name: string; + // (undocumented) + name: string; +} + // @public (undocumented) export type AnalyticsClientConfig = { aws_mobile_analytics_app_id?: string; @@ -135,6 +145,7 @@ interface AWSAmplifyBackendOutputs { storage?: { aws_region: AwsRegion; bucket_name: string; + buckets?: AmplifyStorageBucket[]; }; version: '1'; } @@ -180,7 +191,8 @@ declare namespace clientConfigTypesV1 { AwsAppsyncAuthorizationType, AmazonPinpointChannels, AWSAmplifyBackendOutputs, - AmazonLocationServiceConfig + AmazonLocationServiceConfig, + AmplifyStorageBucket } } export { clientConfigTypesV1 } diff --git a/packages/client-config/src/client-config-contributor/client_config_contributor_v1.test.ts b/packages/client-config/src/client-config-contributor/client_config_contributor_v1.test.ts index 7ea2d6fe6c..2029621fb8 100644 --- a/packages/client-config/src/client-config-contributor/client_config_contributor_v1.test.ts +++ b/packages/client-config/src/client-config-contributor/client_config_contributor_v1.test.ts @@ -533,8 +533,15 @@ void describe('storage client config contributor v1', () => { ); }); - void it('returns translated config when output has auth', () => { + void it('returns translated config when output has storage', () => { const contributor = new StorageClientConfigContributor(); + const buckets = JSON.stringify([ + JSON.stringify({ + name: 'testName', + bucketName: 'testBucketName', + storageRegion: 'testRegion', + }), + ]); assert.deepStrictEqual( contributor.contribute({ [storageOutputKey]: { @@ -542,6 +549,7 @@ void describe('storage client config contributor v1', () => { payload: { bucketName: 'testBucketName', storageRegion: 'testRegion', + buckets, }, }, }), @@ -549,8 +557,15 @@ void describe('storage client config contributor v1', () => { storage: { aws_region: 'testRegion', bucket_name: 'testBucketName', + buckets: [ + { + name: 'testName', + bucket_name: 'testBucketName', + aws_region: 'testRegion', + }, + ], }, - } as Partial + } ); }); }); diff --git a/packages/client-config/src/client-config-contributor/client_config_contributor_v1.ts b/packages/client-config/src/client-config-contributor/client_config_contributor_v1.ts index 6336bb9d79..4f4c2e8eec 100644 --- a/packages/client-config/src/client-config-contributor/client_config_contributor_v1.ts +++ b/packages/client-config/src/client-config-contributor/client_config_contributor_v1.ts @@ -12,7 +12,7 @@ import { clientConfigTypesV1, } from '../client-config-types/client_config.js'; import { ModelIntrospectionSchemaAdapter } from '../model_introspection_schema_adapter.js'; -import { AwsAppsyncAuthorizationType } from '../client-config-schema/client_config_v1.js'; +import { AwsAppsyncAuthorizationType } from '../client-config-schema/client_config_v1.1.js'; // All categories client config contributors are included here to mildly enforce them using // the same schema (version and other types) @@ -258,10 +258,29 @@ export class StorageClientConfigContributor implements ClientConfigContributor { return {}; } const config: Partial = {}; - + const bucketsStringArray = JSON.parse( + storageOutput.payload.buckets ?? '[]' + ); config.storage = { aws_region: storageOutput.payload.storageRegion, bucket_name: storageOutput.payload.bucketName, + buckets: bucketsStringArray + .map((b: string) => JSON.parse(b)) + .map( + ({ + name, + bucketName, + storageRegion, + }: { + name: string; + bucketName: string; + storageRegion: string; + }) => ({ + name, + bucket_name: bucketName, + aws_region: storageRegion, + }) + ), }; return config; diff --git a/packages/client-config/src/client-config-schema/client_config_v1.ts b/packages/client-config/src/client-config-schema/client_config_v1.1.ts similarity index 97% rename from packages/client-config/src/client-config-schema/client_config_v1.ts rename to packages/client-config/src/client-config-schema/client_config_v1.1.ts index eff8e48836..d17e72c54e 100644 --- a/packages/client-config/src/client-config-schema/client_config_v1.ts +++ b/packages/client-config/src/client-config-schema/client_config_v1.1.ts @@ -216,6 +216,7 @@ export interface AWSAmplifyBackendOutputs { storage?: { aws_region: AwsRegion; bucket_name: string; + buckets?: AmplifyStorageBucket[]; }; /** * Outputs generated from backend.addOutput({ custom: }) @@ -238,3 +239,8 @@ export interface AmazonLocationServiceConfig { */ style?: string; } +export interface AmplifyStorageBucket { + name: string; + bucket_name: string; + aws_region: string; +} diff --git a/packages/client-config/src/client-config-schema/schema_v1.json b/packages/client-config/src/client-config-schema/schema_v1.1.json similarity index 95% rename from packages/client-config/src/client-config-schema/schema_v1.json rename to packages/client-config/src/client-config-schema/schema_v1.1.json index 80d6508b83..fdea35c4fa 100644 --- a/packages/client-config/src/client-config-schema/schema_v1.json +++ b/packages/client-config/src/client-config-schema/schema_v1.1.json @@ -337,6 +337,12 @@ }, "bucket_name": { "type": "string" + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/$defs/amplify_storage_bucket" + } } }, "required": ["aws_region", "bucket_name"] @@ -348,6 +354,22 @@ }, "required": ["version"], "$defs": { + "amplify_storage_bucket": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "bucket_name": { + "type": "string" + }, + "aws_region": { + "type": "string" + } + }, + "required": ["bucket_name", "aws_region", "name"] + }, "aws_region": { "type": "string" }, diff --git a/packages/client-config/src/client-config-types/client_config.ts b/packages/client-config/src/client-config-types/client_config.ts index 996de528ed..006230ba98 100644 --- a/packages/client-config/src/client-config-types/client_config.ts +++ b/packages/client-config/src/client-config-types/client_config.ts @@ -8,7 +8,7 @@ import { AnalyticsClientConfig } from './analytics_client_config.js'; import { NotificationsClientConfig } from './notifications_client_config.js'; // Versions of new unified config schemas -import * as clientConfigTypesV1 from '../client-config-schema/client_config_v1.js'; +import * as clientConfigTypesV1 from '../client-config-schema/client_config_v1.1.js'; /** * Merged type of all category client config legacy types diff --git a/packages/deployed-backend-client/src/backend_output_client.ts b/packages/deployed-backend-client/src/backend_output_client.ts index a856303cc6..3d5c9b72de 100644 --- a/packages/deployed-backend-client/src/backend_output_client.ts +++ b/packages/deployed-backend-client/src/backend_output_client.ts @@ -21,7 +21,9 @@ export class DefaultBackendOutputClient implements BackendOutputClient { this.cloudFormationClient, this.amplifyClient ).getStrategy(backendIdentifier); + const output = await outputFetcher.fetchBackendOutput(); + return unifiedBackendOutputSchema.parse(output); }; } diff --git a/packages/deployed-backend-client/src/deployed_backend_client.ts b/packages/deployed-backend-client/src/deployed_backend_client.ts index c188b82761..94b18def4c 100644 --- a/packages/deployed-backend-client/src/deployed_backend_client.ts +++ b/packages/deployed-backend-client/src/deployed_backend_client.ts @@ -307,9 +307,8 @@ export class DefaultDeployedBackendClient implements DeployedBackendClient { } if (apiStack) { - const additionalAuthTypesString = - backendOutput[graphqlOutputKey]?.payload - .awsAppsyncAdditionalAuthenticationTypes; + const additionalAuthTypesString = backendOutput[graphqlOutputKey]?.payload + .awsAppsyncAdditionalAuthenticationTypes as string; const additionalAuthTypes = additionalAuthTypesString ? (additionalAuthTypesString.split(',') as ApiAuthType[]) : []; @@ -340,7 +339,7 @@ export class DefaultDeployedBackendClient implements DeployedBackendClient { const definedFunctionsString = backendOutput[functionOutputKey]?.payload.definedFunctions; const customerFunctionNames = definedFunctionsString - ? (JSON.parse(definedFunctionsString) as string[]) + ? (JSON.parse(definedFunctionsString as string) as string[]) : []; customerFunctionNames.forEach((functionName) => { diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 770740398d..07032f46bf 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -25,6 +25,7 @@ "@aws-sdk/client-sts": "^3.465.0", "@aws-sdk/credential-providers": "^3.465.0", "@smithy/shared-ini-file-loader": "^2.2.5", + "@types/lodash.ismatch": "^4.4.9", "aws-amplify": "^6.0.16", "aws-appsync-auth-link": "^3.0.7", "aws-cdk-lib": "^2.132.0", @@ -33,6 +34,7 @@ "fs-extra": "^11.1.1", "glob": "^10.2.7", "graphql-tag": "^2.12.6", + "lodash.ismatch": "^4.4.0", "node-fetch": "^3.3.2", "semver": "^7.5.4", "ssh2": "^1.15.0", diff --git a/packages/integration-tests/src/test-project-setup/test_project_base.ts b/packages/integration-tests/src/test-project-setup/test_project_base.ts index a37a7cddc5..c6ab0284fe 100644 --- a/packages/integration-tests/src/test-project-setup/test_project_base.ts +++ b/packages/integration-tests/src/test-project-setup/test_project_base.ts @@ -24,6 +24,7 @@ import { BackendOutputClientFactory as CurrentCodebaseBackendOutputClientFactory import path from 'path'; import { AmplifyClient } from '@aws-sdk/client-amplify'; import { pathToFileURL } from 'url'; +import isMatch from 'lodash.ismatch'; export type PlatformDeploymentThresholds = { onWindows: number; @@ -183,6 +184,6 @@ export abstract class TestProjectBase { await currentCodebaseBackendOutputClient.getOutput(backendId); const npmOutputs = await npmBackendOutputClient.getOutput(backendId); - assert.deepStrictEqual(currentCodebaseOutputs, npmOutputs); + assert.ok(isMatch(currentCodebaseOutputs, npmOutputs)); } } diff --git a/packages/plugin-types/API.md b/packages/plugin-types/API.md index cac0b4ad81..58b1302c88 100644 --- a/packages/plugin-types/API.md +++ b/packages/plugin-types/API.md @@ -90,7 +90,7 @@ export type BackendOutputRetrievalStrategy = { // @public export type BackendOutputStorageStrategy = { addBackendOutputEntry: (keyName: string, backendOutputEntry: T) => void; - appendToBackendOutputList: (keyName: string, backendOutputEntry: T) => void; + appendToBackendOutputList: (keyName: string, backendOutputEntry: DeepPartial) => void; }; // @public (undocumented) @@ -135,6 +135,11 @@ export type ConstructFactoryGetInstanceProps = { resourceNameValidator?: ResourceNameValidator; }; +// @public +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + // @public export type DeepPartialAmplifyGeneratedConfigs = { [P in keyof T]?: P extends 'auth' | 'data' | 'storage' ? T[P] extends object ? DeepPartialAmplifyGeneratedConfigs : Partial : T[P]; diff --git a/packages/plugin-types/src/deep_partial.ts b/packages/plugin-types/src/deep_partial.ts index bead10f809..d1caee668c 100644 --- a/packages/plugin-types/src/deep_partial.ts +++ b/packages/plugin-types/src/deep_partial.ts @@ -1,3 +1,13 @@ +/** + * Represents a type that allows partial deep cloning of an object. + * The `DeepPartial` type recursively makes all properties of `T` optional. + * If a property is an object, it will also be made partially optional. + * @template T - The type of the object to make partially optional. + */ +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + /** * Makes all the properties of the entire nested object partial for Amplify generated configs * instead of just the top level properties. Other properties are not changed. diff --git a/packages/plugin-types/src/output_storage_strategy.ts b/packages/plugin-types/src/output_storage_strategy.ts index 560662bdae..90ddfd3d84 100644 --- a/packages/plugin-types/src/output_storage_strategy.ts +++ b/packages/plugin-types/src/output_storage_strategy.ts @@ -1,9 +1,13 @@ import { BackendOutputEntry } from './backend_output.js'; +import { DeepPartial } from './deep_partial.js'; /** * Type for an object that collects output data from constructs */ export type BackendOutputStorageStrategy = { addBackendOutputEntry: (keyName: string, backendOutputEntry: T) => void; - appendToBackendOutputList: (keyName: string, backendOutputEntry: T) => void; + appendToBackendOutputList: ( + keyName: string, + backendOutputEntry: DeepPartial + ) => void; };