From 1e4100c5784a51d77f19329e160e1e32d8a01e3e Mon Sep 17 00:00:00 2001 From: Ghosh Date: Thu, 30 Sep 2021 10:56:40 -0700 Subject: [PATCH 1/7] feat: ddb overrides --- .../amplify-plugin.json | 2 +- .../amplify-category-storage/package.json | 16 +- .../resources/genInputSchema.ts | 17 + .../overrides-resource/DynamoDB/override.ts | 6 + .../overrides-resource/DynamoDB/package.json | 18 + .../overrides-resource/DynamoDB/tsconfig.json | 12 + .../DynamoDB/tsconfig.resource.json | 13 + .../overrides-resource/S3/override.ts | 6 + .../overrides-resource/S3/package.json | 18 + .../overrides-resource/S3/tsconfig.json | 12 + .../S3/tsconfig.resource.json | 13 + .../src/commands/storage/override.ts | 65 ++ .../amplify-category-storage/src/index.ts | 153 ++-- .../cdk-stack-builder/ddb-stack-builder.ts | 188 ++++ .../cdk-stack-builder/ddb-stack-transform.ts | 225 +++++ .../cdk-stack-builder/s3-stack-builder.ts | 5 + .../cdk-stack-builder/types.ts | 20 + .../awscloudformation/cfn-template-utils.ts | 9 +- ...amoDb-defaults.js => dynamoDb-defaults.ts} | 4 +- .../{s3-defaults.js => s3-defaults.ts} | 4 +- .../dynamoDB/DynamoDBCLIInputs.schema.json | 64 ++ .../schemas/s3/S3UserInputs.schema.json | 50 + .../dynamoDB-user-input-types.ts | 41 + .../s3-user-input-types.ts | 28 + .../dynamoDB-input-state.ts | 58 ++ ...walkthrough.js => dynamoDb-walkthrough.ts} | 865 +++++++++--------- .../s3-user-input-state.ts | 162 ++++ .../amplify-category-storage/tsconfig.json | 4 +- packages/amplify-cli-core/src/cliConstants.ts | 2 +- .../src/feature-flags/featureFlags.ts | 6 + .../src/state-manager/pathManager.ts | 11 + packages/amplify-cli/amplify-plugin.json | 3 +- packages/amplify-cli/package.json | 4 +- 33 files changed, 1571 insertions(+), 533 deletions(-) create mode 100644 packages/amplify-category-storage/resources/genInputSchema.ts create mode 100644 packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts create mode 100644 packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json create mode 100644 packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json create mode 100644 packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json create mode 100644 packages/amplify-category-storage/resources/overrides-resource/S3/override.ts create mode 100644 packages/amplify-category-storage/resources/overrides-resource/S3/package.json create mode 100644 packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json create mode 100644 packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json create mode 100644 packages/amplify-category-storage/src/commands/storage/override.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts rename packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/{dynamoDb-defaults.js => dynamoDb-defaults.ts} (80%) rename packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/{s3-defaults.js => s3-defaults.ts} (91%) create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts rename packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/{dynamoDb-walkthrough.js => dynamoDb-walkthrough.ts} (50%) create mode 100644 packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts diff --git a/packages/amplify-category-storage/amplify-plugin.json b/packages/amplify-category-storage/amplify-plugin.json index 6eabe87cf19..b46d4d9565b 100644 --- a/packages/amplify-category-storage/amplify-plugin.json +++ b/packages/amplify-category-storage/amplify-plugin.json @@ -1,7 +1,7 @@ { "name": "storage", "type": "category", - "commands": ["add", "import", "console", "push", "remove", "update", "help"], + "commands": ["add", "import", "console", "push", "remove", "update", "help", "override"], "commandAliases": { "configure": "update" }, diff --git a/packages/amplify-category-storage/package.json b/packages/amplify-category-storage/package.json index bd53c0c6e62..865941812ec 100644 --- a/packages/amplify-category-storage/package.json +++ b/packages/amplify-category-storage/package.json @@ -1,6 +1,6 @@ { - "name": "amplify-category-storage", - "version": "2.12.9", + "name": "@aws-amplify/amplify-category-storage", + "version": "1.0.0", "description": "amplify-cli storage plugin", "repository": { "type": "git", @@ -15,7 +15,8 @@ "build": "tsc", "test": "jest", "clean": "rimraf lib tsconfig.tsbuildinfo", - "watch": "tsc -w" + "watch": "tsc -w", + "generateSchemas": "ts-node ./resources/genInputSchema.ts" }, "keywords": [ "amplify", @@ -27,6 +28,9 @@ "amplify-util-import": "1.5.12", "chalk": "^4.1.1", "cloudform-types": "^4.2.0", + "@aws-cdk/aws-s3": "~1.124.0", + "@aws-cdk/core": "~1.124.0", + "@aws-cdk/aws-dynamodb": "~1.124.0", "enquirer": "^2.3.6", "fs-extra": "^8.1.0", "inquirer": "^7.3.3", @@ -52,6 +56,10 @@ "json", "node" ], - "collectCoverage": true + "collectCoverage": true, + "coverageReporters": [ + "json", + "html" + ] } } diff --git a/packages/amplify-category-storage/resources/genInputSchema.ts b/packages/amplify-category-storage/resources/genInputSchema.ts new file mode 100644 index 00000000000..b60d8436d55 --- /dev/null +++ b/packages/amplify-category-storage/resources/genInputSchema.ts @@ -0,0 +1,17 @@ +import { TypeDef, CLIInputSchemaGenerator } from 'amplify-cli-core'; + +//ResourceProvider TypeDefs +const DDBStorageTypeDef: TypeDef = { + typeName: 'DynamoDBCLIInputs', + service: 'dynamoDB', +}; +const S3StorageTypeDef: TypeDef = { + typeName: 'S3UserInputs', + service: 's3', +}; + +// Defines the type names and the paths to the TS files that define them +const storageCategoryTypeDefs: TypeDef[] = [DDBStorageTypeDef]; + +const schemaGenerator = new CLIInputSchemaGenerator(storageCategoryTypeDefs); +schemaGenerator.generateJSONSchemas(); //convert CLI input data into json schemas. diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts new file mode 100644 index 00000000000..c02cee5ebd4 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts @@ -0,0 +1,6 @@ +/* Add Amplify Helper dependencies */ + +/* TODO: Need to change props to Root-Stack specific props when props are ready */ +export function overrideProps(props: any): void { + /* TODO: Add snippet of how to override in comments */ +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json new file mode 100644 index 00000000000..1601dccfc02 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json @@ -0,0 +1,18 @@ +{ + "name": "overrides-for-root-stack", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "fs-extra": "^9.1.0" + }, + "devDependencies": { + "typescript": "^4.2.4" + } + } + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json new file mode 100644 index 00000000000..f48614cbf6b --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + } +} + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json new file mode 100644 index 00000000000..6504da80283 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./", + "rootDir": "../" + }, + "include": ["../**/*"] +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts b/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts new file mode 100644 index 00000000000..c02cee5ebd4 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts @@ -0,0 +1,6 @@ +/* Add Amplify Helper dependencies */ + +/* TODO: Need to change props to Root-Stack specific props when props are ready */ +export function overrideProps(props: any): void { + /* TODO: Add snippet of how to override in comments */ +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/package.json b/packages/amplify-category-storage/resources/overrides-resource/S3/package.json new file mode 100644 index 00000000000..1601dccfc02 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/package.json @@ -0,0 +1,18 @@ +{ + "name": "overrides-for-root-stack", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "fs-extra": "^9.1.0" + }, + "devDependencies": { + "typescript": "^4.2.4" + } + } + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json new file mode 100644 index 00000000000..f48614cbf6b --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + } +} + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json new file mode 100644 index 00000000000..6504da80283 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./", + "rootDir": "../" + }, + "include": ["../**/*"] +} diff --git a/packages/amplify-category-storage/src/commands/storage/override.ts b/packages/amplify-category-storage/src/commands/storage/override.ts new file mode 100644 index 00000000000..18d6cd6a2bf --- /dev/null +++ b/packages/amplify-category-storage/src/commands/storage/override.ts @@ -0,0 +1,65 @@ +/* + entry code for amplify override root +*/ + +import path from 'path'; +import { generateOverrideSkeleton, $TSContext, FeatureFlags } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as fs from 'fs-extra'; +import inquirer from 'inquirer'; + +const category = 'storage'; +export const name = 'override'; + +export const run = async (context: $TSContext) => { + if (FeatureFlags.getBoolean('overrides.project')) { + const { amplify } = context; + const { amplifyMeta } = amplify.getProjectDetails(); + + const storageResources: string[] = []; + + Object.keys(amplifyMeta[category]).forEach(resourceName => { + storageResources.push(resourceName); + }); + + if (storageResources.length === 0) { + const errMessage = 'No resources to override. You need to add a resource.'; + printer.error(errMessage); + return; + } + + let selectedResource: string = storageResources[0]; + + if (storageResources.length > 1) { + const resourceAnswer = await inquirer.prompt({ + type: 'list', + name: 'resource', + message: 'Which resource would you like to add overrides for?', + choices: storageResources, + }); + selectedResource = resourceAnswer.resource; + } + + const backendDir = context.amplify.pathManager.getBackendDirPath(); + const destPath = path.join(backendDir, category, selectedResource); + fs.ensureDirSync(destPath); + + const srcPath = path.join( + __dirname, + '..', + '..', + '..', + 'resources', + 'overrides-resource', + amplifyMeta[category][selectedResource].service, + ); + await generateOverrideSkeleton(context, srcPath, destPath); + } else { + printer.info('Storage overrides is currently not turned on. In amplify/cli.json file please include the following:'); + printer.info(`{ + override: { + storage: true + } + }`); + } +}; diff --git a/packages/amplify-category-storage/src/index.ts b/packages/amplify-category-storage/src/index.ts index 5b4d07b74bb..9f38de2d70a 100644 --- a/packages/amplify-category-storage/src/index.ts +++ b/packages/amplify-category-storage/src/index.ts @@ -1,76 +1,94 @@ -import { $TSAny, $TSContext, $TSObject, stateManager } from 'amplify-cli-core'; -import { printer } from 'amplify-prompts'; import * as path from 'path'; -import sequential from 'promise-sequential'; +const sequential = require('promise-sequential'); import { updateConfigOnEnvInit } from './provider-utils/awscloudformation'; -import { categoryName } from './constants'; -export { categoryName as category } from './constants'; +import { $TSContext, AmplifyCategories, IAmplifyResource } from 'amplify-cli-core'; +import { DDBStackTransform } from './provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +export { AmplifyDDBResourceTemplate } from './provider-utils/awscloudformation/cdk-stack-builder/types'; -export async function add(context: $TSContext, providerName: string, service: string) { +async function add(context: any, providerName: any, service: any) { const options = { service, providerPlugin: providerName, }; - const providerController = await import(`./provider-utils/${providerName}`); + const providerController = require(`./provider-utils/${providerName}`); if (!providerController) { - printer.error('Provider not configured for this category'); + context.print.error('Provider not configured for this category'); return; } - return providerController.addResource(context, categoryName, service, options); + return providerController.addResource(context, AmplifyCategories.STORAGE, service, options); } -export async function console(context: $TSContext) { - printer.info(`to be implemented: ${categoryName} console`); +async function categoryConsole(context: any) { + context.print.info(`to be implemented: ${AmplifyCategories.STORAGE} console`); } -export async function migrate(context: $TSContext) { +async function migrateStorageCategory(context: any) { const { projectPath, amplifyMeta } = context.migrationInfo; - const migrateResourcePromises: Promise<$TSAny>[] = []; - - const categoryResources = amplifyMeta?.[categoryName] || {}; - - for (const resourceName of Object.keys(categoryResources)) { - try { - const providerController = await import(`./provider-utils/${amplifyMeta[categoryName][resourceName].providerPlugin}`); - - if (providerController) { - migrateResourcePromises.push( - await providerController.migrateResource(context, projectPath, amplifyMeta[categoryName][resourceName].service, resourceName), - ); - } else { - printer.error(`Provider not configured for ${categoryName}: ${resourceName}`); - } - } catch (e) { - printer.warn(`Could not run migration for ${categoryName}: ${resourceName}`); - throw e; + const migrateResourcePromises: any = []; + + Object.keys(amplifyMeta).forEach(categoryName => { + if (categoryName === AmplifyCategories.STORAGE) { + Object.keys(amplifyMeta[AmplifyCategories.STORAGE]).forEach(resourceName => { + try { + const providerController = require(`./provider-utils/${amplifyMeta[AmplifyCategories.STORAGE][resourceName].providerPlugin}`); + + if (providerController) { + migrateResourcePromises.push( + providerController.migrateResource( + context, + projectPath, + amplifyMeta[AmplifyCategories.STORAGE][resourceName].service, + resourceName, + ), + ); + } else { + context.print.error(`Provider not configured for ${AmplifyCategories.STORAGE}: ${resourceName}`); + } + } catch (e) { + context.print.warning(`Could not run migration for ${AmplifyCategories.STORAGE}: ${resourceName}`); + throw e; + } + }); } - } + }); await Promise.all(migrateResourcePromises); } -export async function getPermissionPolicies(context: $TSContext, resourceOpsMapping: $TSAny) { - const amplifyMeta = stateManager.getMeta(); - const permissionPolicies: $TSAny[] = []; - const resourceAttributes: $TSAny[] = []; +async function transformCategoryStack(context: $TSContext, resource: IAmplifyResource) { + if (resource.service === 'DynamoDB') { + const stackGenerator = new DDBStackTransform(resource.resourceName); + stackGenerator.transform(); + } else if (resource.service === 'DynamoDB') { + // Not yet implemented + } +} + +async function getPermissionPolicies(context: any, resourceOpsMapping: any) { + const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); + const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); + const permissionPolicies: any = []; + const resourceAttributes: any = []; + const storageCategory = AmplifyCategories.STORAGE; - for (const resourceName of Object.keys(resourceOpsMapping)) { + Object.keys(resourceOpsMapping).forEach(resourceName => { try { const providerPlugin = 'providerPlugin' in resourceOpsMapping[resourceName] ? resourceOpsMapping[resourceName].providerPlugin - : amplifyMeta[categoryName][resourceName].providerPlugin; + : amplifyMeta[storageCategory][resourceName].providerPlugin; const service = 'service' in resourceOpsMapping[resourceName] ? resourceOpsMapping[resourceName].service - : amplifyMeta[categoryName][resourceName].service; + : amplifyMeta[storageCategory][resourceName].service; if (providerPlugin) { - const providerController = await import(`./provider-utils/${providerPlugin}`); - const { policy, attributes } = await providerController.getPermissionPolicies( + const providerController = require(`./provider-utils/${providerPlugin}`); + const { policy, attributes } = providerController.getPermissionPolicies( + context, service, resourceName, resourceOpsMapping[resourceName], @@ -80,54 +98,54 @@ export async function getPermissionPolicies(context: $TSContext, resourceOpsMapp } else { permissionPolicies.push(policy); } - resourceAttributes.push({ resourceName, attributes, category: categoryName }); + resourceAttributes.push({ resourceName, attributes, storageCategory }); } else { - printer.error(`Provider not configured for ${categoryName}: ${resourceName}`); + context.print.error(`Provider not configured for ${storageCategory}: ${resourceName}`); } } catch (e) { - printer.warn(`Could not get policies for ${categoryName}: ${resourceName}`); + context.print.warning(`Could not get policies for ${storageCategory}: ${resourceName}`); throw e; } - } + }); return { permissionPolicies, resourceAttributes }; } -export async function executeAmplifyCommand(context: $TSContext) { +async function executeAmplifyCommand(context: any) { let commandPath = path.normalize(path.join(__dirname, 'commands')); if (context.input.command === 'help') { - commandPath = path.join(commandPath, categoryName); + commandPath = path.join(commandPath, AmplifyCategories.STORAGE); } else { - commandPath = path.join(commandPath, categoryName, context.input.command); + commandPath = path.join(commandPath, AmplifyCategories.STORAGE, context.input.command); } - const commandModule = await import(commandPath); + const commandModule = require(commandPath); await commandModule.run(context); } -export async function handleAmplifyEvent(context: $TSContext, args: $TSAny) { - printer.info(`${categoryName} handleAmplifyEvent to be implemented`); - printer.info(`Received event args ${args}`); +async function handleAmplifyEvent(context: any, args: any) { + context.print.info(`${AmplifyCategories.STORAGE} handleAmplifyEvent to be implemented`); + context.print.info(`Received event args ${args}`); } -export async function initEnv(context: $TSContext) { - const { resourcesToBeSynced, allResources } = await context.amplify.getResourceStatus(categoryName); +async function initEnv(context: any) { + const { resourcesToBeSynced, allResources } = await context.amplify.getResourceStatus(AmplifyCategories.STORAGE); const isPulling = context.input.command === 'pull' || (context.input.command === 'env' && context.input.subCommands[0] === 'pull'); - let toBeSynced: $TSObject[] = []; + let toBeSynced = []; if (resourcesToBeSynced && resourcesToBeSynced.length > 0) { - toBeSynced = resourcesToBeSynced.filter((b: $TSObject) => b.category === categoryName); + toBeSynced = resourcesToBeSynced.filter((b: any) => b.category === AmplifyCategories.STORAGE); } toBeSynced - .filter(storageResource => storageResource.sync === 'unlink') - .forEach(storageResource => { - context.amplify.removeResourceParameters(context, categoryName, storageResource.resourceName); + .filter((storageResource: any) => storageResource.sync === 'unlink') + .forEach((storageResource: any) => { + context.amplify.removeResourceParameters(context, AmplifyCategories.STORAGE, storageResource.resourceName); }); - let tasks: $TSAny[] = []; + let tasks: Record[] = []; // For pull change detection for import sees a difference, to avoid duplicate tasks we don't // add the syncable resources, as allResources covers it, otherwise it is required for env add @@ -145,11 +163,22 @@ export async function initEnv(context: $TSContext) { const { resourceName, service } = storageResource; return async () => { - const config = await updateConfigOnEnvInit(context, categoryName, resourceName, service); - - context.amplify.saveEnvResourceParameters(context, categoryName, resourceName, config); + const config = await updateConfigOnEnvInit(context, AmplifyCategories.STORAGE, resourceName, service); + context.amplify.saveEnvResourceParameters(context, AmplifyCategories.STORAGE, resourceName, config); }; }); await sequential(storageTasks); } + +module.exports = { + add, + console: categoryConsole, + initEnv, + migrate: migrateStorageCategory, + getPermissionPolicies, + executeAmplifyCommand, + handleAmplifyEvent, + transformCategoryStack, + category: AmplifyCategories.STORAGE, +}; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts new file mode 100644 index 00000000000..44dad1f403a --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts @@ -0,0 +1,188 @@ +import * as cdk from '@aws-cdk/core'; +import * as ddb from '@aws-cdk/aws-dynamodb'; +import { DynamoDBCLIInputs, DynamoDBCLIInputsKeyType, FieldType } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { AmplifyDDBResourceTemplate } from './types'; + +const CFN_TEMPLATE_FORMAT_VERSION = '2010-09-09'; +const ROOT_CFN_DESCRIPTION = 'DDB Resource for AWS Amplify CLI'; + +export class AmplifyDDBResourceStack extends cdk.Stack implements AmplifyDDBResourceTemplate { + _scope: cdk.Construct; + dynamoDBTable!: ddb.CfnTable; + _props: DynamoDBCLIInputs; + _cfnParameterMap: Map = new Map(); + + constructor(scope: cdk.Construct, id: string, props: DynamoDBCLIInputs) { + super(scope, id, undefined); + this._scope = scope; + this._props = props; + this.templateOptions.templateFormatVersion = CFN_TEMPLATE_FORMAT_VERSION; + this.templateOptions.description = ROOT_CFN_DESCRIPTION; + } + + /** + * + * @param props :cdk.CfnOutputProps + * @param logicalId: : lodicalId of the Resource + */ + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void { + try { + new cdk.CfnOutput(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void { + try { + new cdk.CfnMapping(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void { + try { + new cdk.CfnCondition(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + /** + * + * @param props + * @param logicalId + */ + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void { + try { + new cdk.CfnResource(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void { + try { + if (this._cfnParameterMap.has(logicalId)) { + throw new Error('logical Id already Exists'); + } + this._cfnParameterMap.set(logicalId, new cdk.CfnParameter(this, logicalId, props)); + } catch (error) { + throw new Error(error); + } + } + + generateStackResources = async () => { + let usedAttributes: DynamoDBCLIInputsKeyType[] = []; + let keySchema: ddb.CfnTable.KeySchemaProperty[] = []; + let globalSecondaryIndexes: ddb.CfnTable.GlobalSecondaryIndexProperty[] = []; + + if (this._props.partitionKey) { + usedAttributes.push(this._props.partitionKey); + keySchema.push({ + attributeName: this._props.partitionKey.fieldName, + keyType: 'HASH', + }); + } + if (this._props.sortKey) { + usedAttributes.push(this._props.sortKey); + keySchema.push({ + attributeName: this._props.sortKey.fieldName, + keyType: 'RANGE', + }); + } + if (this._props.gsi && this._props.gsi.length > 0) { + this._props.gsi.forEach(gsi => { + let gsiIndex = { + indexName: gsi.name, + keySchema: [ + { + attributeName: gsi.partitionKey.fieldName, + keyType: 'HASH', + }, + ], + projection: { + projectionType: 'ALL', + }, + provisionedThroughput: { + readCapacityUnits: 5, + writeCapacityUnits: 5, + }, + }; + + if (usedAttributes.findIndex(attr => attr.fieldName === gsi.partitionKey.fieldName) === -1) { + usedAttributes.push(gsi.partitionKey); + } + if (gsi.sortKey) { + gsiIndex.keySchema.push({ + attributeName: gsi.sortKey?.fieldName, + keyType: 'RANGE', + }); + if (usedAttributes.findIndex(attr => attr?.fieldName === gsi.sortKey?.fieldName) == -1) { + usedAttributes.push(gsi.sortKey); + } + } + globalSecondaryIndexes.push(gsiIndex); + }); + } + + const ddbAttrTypeMapping = { + string: 'S', + number: 'N', + binary: 'B', + boolean: 'BOOL', + list: 'L', + map: 'M', + null: 'NULL', + 'string-set': 'SS', + 'number-set': 'NS', + 'binary-set': 'BS', + }; + + let attributeMapping: ddb.CfnTable.AttributeDefinitionProperty[] = []; + + usedAttributes.forEach((attr: DynamoDBCLIInputsKeyType) => { + attributeMapping.push({ + attributeName: attr.fieldName, + attributeType: ddbAttrTypeMapping[attr.fieldType], + }); + }); + + this.dynamoDBTable = new ddb.CfnTable(this, 'DynamoDBTable', { + tableName: cdk.Fn.conditionIf( + 'ShouldNotCreateEnvResources', + cdk.Fn.ref('tableName'), + cdk.Fn.join('', [cdk.Fn.ref('tableName'), '-', cdk.Fn.ref('env')]), + ).toString(), + attributeDefinitions: attributeMapping, + keySchema, + globalSecondaryIndexes, + provisionedThroughput: { + readCapacityUnits: 5, + writeCapacityUnits: 5, + }, + streamSpecification: { + streamViewType: 'NEW_IMAGE', + }, + }); + }; + + public renderCloudFormationTemplate = (): string => { + return JSON.stringify(this._toCloudFormation(), undefined, 2); + }; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts new file mode 100644 index 00000000000..20d361f3b33 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts @@ -0,0 +1,225 @@ +import { DynamoDBCLIInputs } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { DynamoDBInputState } from '../service-walkthroughs/dynamoDB-input-state'; +import { AmplifyDDBResourceStack } from './ddb-stack-builder'; +import { AmplifyDDBResourceInputParameters, AmplifyDDBResourceTemplate } from './types'; +import { App } from '@aws-cdk/core'; +import * as cdk from '@aws-cdk/core'; +import * as fs from 'fs-extra'; +import { JSONUtilities, pathManager, buildOverrideDir } from 'amplify-cli-core'; +import path from 'path'; +import { formatter, printer } from 'amplify-prompts'; + +export class DDBStackTransform { + app: App; + cliInputs: DynamoDBCLIInputs; + _resourceTemplateObj: AmplifyDDBResourceStack | undefined; + cliInputsState: DynamoDBInputState; + cfn!: string; + cfnInputParams!: AmplifyDDBResourceInputParameters; + _resourceName: string; + + constructor(resourceName: string) { + this.app = new App(); + this._resourceName = resourceName; + + // Validate the cli-inputs.json for the resource + this.cliInputsState = new DynamoDBInputState(resourceName); + this.cliInputs = this.cliInputsState.getCliInputPayload(); + this.cliInputsState.isCLIInputsValid(); + } + + async transform() { + // Generate cloudformation stack from cli-inputs.json + await this.generateStack(); + + // Generate cloudformation stack from cli-inputs.json + this.generateCfnInputParameters(); + + // Modify cloudformation files based on overrides + await this.applyOverrides(); + + // Save generated cloudformation.json and parameters.json files + this.saveBuildFiles(); + } + + generateCfnInputParameters() { + this.cfnInputParams = { + tableName: this.cliInputs.tableName, + partitionKeyName: this.cliInputs.partitionKey.fieldName, + partitionKeyType: this.cliInputs.partitionKey.fieldType, + }; + if (this.cliInputs.sortKey) { + this.cfnInputParams.sortKeyName = this.cliInputs.sortKey.fieldName; + this.cfnInputParams.sortKeyType = this.cliInputs.sortKey.fieldType; + } + } + + async generateStack() { + this._resourceTemplateObj = new AmplifyDDBResourceStack(this.app, 'AmplifyDDBResourceStack', this.cliInputs); + + // Add Parameters + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'partitionKeyName', + ); + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'partitionKeyType', + ); + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'env', + ); + if (this.cliInputs.sortKey) { + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'sortKeyName', + ); + + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'sortKeyType', + ); + } + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'tableName', + ); + + // Add conditions + + this._resourceTemplateObj.addCfnCondition( + { + expression: cdk.Fn.conditionEquals(cdk.Fn.ref('env'), 'NONE'), + }, + 'ShouldNotCreateEnvResources', + ); + + // Add resources + + await this._resourceTemplateObj.generateStackResources(); + + // Add outputs + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('DynamoDBTable'), + }, + 'Name', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.getAtt('DynamoDBTable', 'Arn').toString(), + }, + 'Arn', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.getAtt('DynamoDBTable', 'StreamArn').toString(), + }, + 'StreamArn', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('partitionKeyName'), + }, + 'PartitionKeyName', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('partitionKeyType'), + }, + 'PartitionKeyType', + ); + + if (this.cliInputs.sortKey) { + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('sortKeyName'), + }, + 'SortKeyName', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('sortKeyType'), + }, + 'SortKeyType', + ); + } + + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('AWS::Region'), + }, + 'Region', + ); + } + + async applyOverrides() { + const backendDir = pathManager.getBackendDirPath(); + const overrideFilePath = path.join(backendDir, 'storage', this._resourceName); + + const isBuild = await buildOverrideDir(backendDir, overrideFilePath).catch(error => { + printer.warn(`Skipping build as ${error.message}`); + return false; + }); + // skip if packageManager or override.ts not found + if (isBuild) { + const { overrideProps } = await import(path.join(overrideFilePath, 'build', 'override.js')).catch(error => { + formatter.list(['No override File Found', `To override ${this._resourceName} run amplify override auth ${this._resourceName} `]); + return undefined; + }); + + // pass stack object + const ddbStackTemplateObj = this._resourceTemplateObj as AmplifyDDBResourceTemplate; + //TODO: Check Script Options + if (typeof overrideProps === 'function' && overrideProps) { + try { + this._resourceTemplateObj = overrideProps(this._resourceTemplateObj as AmplifyDDBResourceTemplate); + + //The vm module enables compiling and running code within V8 Virtual Machine contexts. The vm module is not a security mechanism. Do not use it to run untrusted code. + // const script = new vm.Script(overrideCode); + // script.runInContext(vm.createContext(cognitoStackTemplateObj)); + return; + } catch (error: any) { + throw new Error(error); + } + } + } + } + + saveBuildFiles() { + if (this._resourceTemplateObj) { + this.cfn = JSON.parse(this._resourceTemplateObj.renderCloudFormationTemplate()); + } + + // store files in local-filesysten + + fs.ensureDirSync(this.cliInputsState.buildFilePath); + const cfnFilePath = path.resolve(path.join(this.cliInputsState.buildFilePath, 'cloudformation-template.json')); + try { + JSONUtilities.writeJson(cfnFilePath, this.cfn); + } catch (e) { + throw new Error(e); + } + + fs.ensureDirSync(this.cliInputsState.buildFilePath); + const cfnInputParamsFilePath = path.resolve(path.join(this.cliInputsState.buildFilePath, 'parameters.json')); + try { + JSONUtilities.writeJson(cfnInputParamsFilePath, this.cfnInputParams); + } catch (e) { + throw new Error(e); + } + } +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts new file mode 100644 index 00000000000..3681e1a45e1 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts @@ -0,0 +1,5 @@ +// import * as cdk from '@aws-cdk/core'; +// import * as s3 from '@aws-cdk/aws-s3'; +// import * as iam from '@aws-cdk/aws-iam'; +// import { AmplifyRootStackTemplate } from ''; +// import { IStackSynthesizer, ISynthesisSession } from '@aws-cdk/core'; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts new file mode 100644 index 00000000000..9aa24d95549 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts @@ -0,0 +1,20 @@ +import * as cdk from '@aws-cdk/core'; +import * as ddb from '@aws-cdk/aws-dynamodb'; + +export interface AmplifyDDBResourceTemplate { + dynamoDBTable?: ddb.CfnTable; + + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void; + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void; + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void; + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void; + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void; +} + +export interface AmplifyDDBResourceInputParameters { + tableName: string; + partitionKeyName: string; + partitionKeyType: string; + sortKeyName?: string; + sortKeyType?: string; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts index ae869e23d56..4ac56de2658 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts @@ -3,10 +3,15 @@ import { Template } from 'cloudform-types'; import Table, { AttributeDefinition, GlobalSecondaryIndex } from 'cloudform-types/types/dynamoDb/table'; import _ from 'lodash'; import * as path from 'path'; -const category = 'storage'; +import { AmplifyCategories } from 'amplify-cli-core'; export const getCloudFormationTemplatePath = (resourceName: string) => { - return path.join(pathManager.getBackendDirPath(), category, resourceName, `${resourceName}-cloudformation-template.json`); + return path.join( + pathManager.getBackendDirPath(), + AmplifyCategories.STORAGE, + resourceName, + `${resourceName}-cloudformation-template.json`, + ); }; export const getExistingStorageGSIs = async (resourceName: string) => { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts similarity index 80% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js rename to packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts index fb1983ef6e1..b082c39b563 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import uuid from 'uuid'; -const getAllDefaults = project => { +const getAllDefaults = (project: any) => { const name = project.projectConfig.projectName.toLowerCase(); const [shortId] = uuid().split('-'); const defaults = { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts similarity index 91% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js rename to packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts index 5d1572b7392..54e75718c25 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import uuid from 'uuid'; -const getAllDefaults = project => { +const getAllDefaults = (project: any) => { const name = project.projectConfig.projectName.toLowerCase(); const [shortId] = uuid().split('-'); diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json b/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json new file mode 100644 index 00000000000..1143f79c937 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json @@ -0,0 +1,64 @@ +{ + "type": "object", + "properties": { + "resourceName": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "partitionKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "sortKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "gsi": { + "type": "array", + "items": { + "$ref": "#/definitions/DynamoDBCLIInputsGSIType" + } + }, + "triggerFunctions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["partitionKey", "resourceName", "tableName"], + "definitions": { + "DynamoDBCLIInputsKeyType": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "fieldType": { + "$ref": "#/definitions/FieldType" + } + }, + "required": ["fieldName", "fieldType"] + }, + "FieldType": { + "enum": ["binary", "boolean", "list", "map", "null", "number", "string"], + "type": "string" + }, + "DynamoDBCLIInputsGSIType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "partitionKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "sortKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + } + }, + "required": ["name", "partitionKey"] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json b/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json new file mode 100644 index 00000000000..0254f73d206 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json @@ -0,0 +1,50 @@ +{ + "type": "object", + "properties": { + "resourceName": { + "type": "string" + }, + "bucketName": { + "type": "string" + }, + "storageAccess": { + "$ref": "#/definitions/S3AccessType" + }, + "selectedGuestPermissions": { + "type": "array", + "items": { + "enum": ["DELETE_OBJECT", "GET_OBJECT", "LIST_BUCKET", "PUT_OBJECT"], + "type": "string" + } + }, + "selectedAuthenticatedPermissions": { + "type": "array", + "items": { + "enum": ["DELETE_OBJECT", "GET_OBJECT", "LIST_BUCKET", "PUT_OBJECT"], + "type": "string" + } + }, + "isTriggerEnabled": { + "type": "boolean" + }, + "triggerFunctionName": { + "type": "string" + } + }, + "required": [ + "bucketName", + "isTriggerEnabled", + "resourceName", + "selectedAuthenticatedPermissions", + "selectedGuestPermissions", + "storageAccess", + "triggerFunctionName" + ], + "definitions": { + "S3AccessType": { + "enum": ["auth", "authAndGuest"], + "type": "string" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts new file mode 100644 index 00000000000..7fcfad7b663 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts @@ -0,0 +1,41 @@ +export enum DynamoDBImmutableFields { + resourceName = 'resourceName', + tableName = 'tableName', + partitionKey = 'partitionKey', + sortKey = 'sortKey', +} + +export enum FieldType { + string = 'string', + number = 'number', + binary = 'binary', + boolean = 'boolean', + list = 'list', + map = 'map', + null = 'null', +} + +export interface DynamoDBAttributeDefType { + AttributeName: string; + AttributeType: FieldType; +} + +export interface DynamoDBCLIInputsKeyType { + fieldName: string; + fieldType: FieldType; +} + +export interface DynamoDBCLIInputsGSIType { + name: string; + partitionKey: DynamoDBCLIInputsKeyType; + sortKey?: DynamoDBCLIInputsKeyType; +} + +export interface DynamoDBCLIInputs { + resourceName: string; + tableName: string; + partitionKey: DynamoDBCLIInputsKeyType; + sortKey?: DynamoDBCLIInputsKeyType; + gsi?: DynamoDBCLIInputsGSIType[]; + triggerFunctions?: string[]; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts new file mode 100644 index 00000000000..ba6ed3abcd5 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts @@ -0,0 +1,28 @@ +//user input-data types generated by cli + +export function enumToHelp(obj: object) { + return `One of ${Object.values(obj)}`; +} + +export enum S3AccessType { + AUTH_AND_GUEST = 'authAndGuest', + AUTH_ONLY = 'auth', +} + +export enum S3PermissionType { + PUT_OBJECT = 'PUT_OBJECT', + GET_OBJECT = 'GET_OBJECT', + DELETE_OBJECT = 'DELETE_OBJECT', + LIST_BUCKET = 'LIST_BUCKET', +} + +//User input data for S3 service +export interface S3UserInputs { + resourceName: string; + bucketName: string; + storageAccess: S3AccessType; + selectedGuestPermissions: S3PermissionType[]; + selectedAuthenticatedPermissions: S3PermissionType[]; + isTriggerEnabled: boolean; //enable if trigger + triggerFunctionName: string | undefined; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts new file mode 100644 index 00000000000..5bb7b6692e8 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts @@ -0,0 +1,58 @@ +import { DynamoDBCLIInputs } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; +import { JSONUtilities, pathManager } from 'amplify-cli-core'; +import { CLIInputSchemaValidator } from 'amplify-cli-core'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +/* Need to move this logic to a base class */ + +export class DynamoDBInputState { + _cliInputsFilePath: string; //cli-inputs.json (output) filepath + _resourceName: string; //user friendly name provided by user + _category: string; //category of the resource + _service: string; //AWS service for the resource + buildFilePath: string; + + constructor(resourceName: string) { + this._category = AmplifyCategories.STORAGE; + this._service = AmplifySupportedService.DYNAMODB; + this._resourceName = resourceName; + + const projectBackendDirPath = pathManager.getBackendDirPath(); + this._cliInputsFilePath = path.resolve(path.join(projectBackendDirPath, AmplifyCategories.STORAGE, resourceName, 'cli-inputs.json')); + this.buildFilePath = path.resolve(path.join(projectBackendDirPath, AmplifyCategories.STORAGE, resourceName, 'build')); + } + + public getCliInputPayload(): DynamoDBCLIInputs { + let cliInputs: DynamoDBCLIInputs; + // Read cliInputs file if exists + try { + cliInputs = JSON.parse(fs.readFileSync(this._cliInputsFilePath, 'utf8')); + } catch (e) { + throw new Error('migrate project with command : amplify migrate '); + } + + return cliInputs; + } + + public isCLIInputsValid(cliInputs?: DynamoDBCLIInputs) { + if (!cliInputs) { + cliInputs = this.getCliInputPayload(); + } + + /*const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, "DynamoDBCLIInputs" ); + schemaValidator.validateInput(JSON.stringify(cliInputs));*/ + } + + public saveCliInputPayload(cliInputs: DynamoDBCLIInputs): void { + this.isCLIInputsValid(cliInputs); + + fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); + try { + JSONUtilities.writeJson(this._cliInputsFilePath, cliInputs); + } catch (e) { + throw new Error(e); + } + } +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts similarity index 50% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js rename to packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts index 994fe5e4dba..fcd6a596cda 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -1,49 +1,81 @@ -const { - getCloudFormationTemplatePath, - getExistingStorageAttributeDefinitions, - getExistingStorageGSIs, - getExistingTableColumnNames, -} = require('../cfn-template-utils'); +import * as path from 'path'; +import * as fs from 'fs-extra'; +import uuid from 'uuid'; +import { printer } from 'amplify-prompts'; +import { $TSContext, AmplifyCategories, pathManager, ResourceDoesNotExistError, exitOnNextTick } from 'amplify-cli-core'; +import { DynamoDBInputState } from './dynamoDB-input-state'; +import { + DynamoDBAttributeDefType, + DynamoDBCLIInputs, + DynamoDBCLIInputsGSIType, + DynamoDBCLIInputsKeyType, +} from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { DDBStackTransform } from '../cdk-stack-builder/ddb-stack-transform'; const inquirer = require('inquirer'); -const path = require('path'); -const fs = require('fs-extra'); -const uuid = require('uuid'); -const { ResourceDoesNotExistError, exitOnNextTick } = require('amplify-cli-core'); -const { category } = require('../../..'); - -// keep in sync with ServiceName in amplify-category-function, but probably it will not change +// keep in sync with ServiceName in amplify-AmplifyCategories.STORAGE-function, but probably it will not change const FunctionServiceNameLambdaFunction = 'Lambda'; - -const parametersFileName = 'parameters.json'; -const storageParamsFileName = 'storage-params.json'; const serviceName = 'DynamoDB'; -const templateFileName = 'dynamoDb-cloudformation-template.json.ejs'; -async function addWalkthrough(context, defaultValuesFilename, serviceMetadata) { - return configure(context, defaultValuesFilename, serviceMetadata); +async function addWalkthrough(context: $TSContext, defaultValuesFilename: string) { + printer.info(''); + printer.info('Welcome to the NoSQL DynamoDB database wizard'); + printer.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); + printer.info(''); + + const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); + const { getAllDefaults } = require(defaultValuesSrc); + const { amplify } = context; + const defaultValues = getAllDefaults(amplify.getProjectDetails()); + + const resourceName = await askResourceNameQuestion(context, defaultValues); // Cannot be changed once added + const tableName = await askTableNameQuestion(context, defaultValues, resourceName); // Cannot be changed once added + + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(context); + + const partitionKey = await askPrimaryKeyQuestion(indexableAttributeList, attributeAnswers); // Cannot be changed once added + + let cliInputs: DynamoDBCLIInputs = { + resourceName, + tableName, + partitionKey, + }; + + cliInputs.sortKey = await askSortKeyQuestion(context, indexableAttributeList, attributeAnswers, cliInputs.partitionKey.fieldName); + + cliInputs.gsi = await askGSIQuestion(context, indexableAttributeList, attributeAnswers); + + cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName); + + const cliInputsState = new DynamoDBInputState(cliInputs.resourceName); + cliInputsState.saveCliInputPayload(cliInputs); + + const stackGenerator = new DDBStackTransform(cliInputs.resourceName); + stackGenerator.transform(); + + return cliInputs.resourceName; } -async function updateWalkthrough(context, defaultValuesFilename, serviceMetadata) { +async function updateWalkthrough(context: $TSContext) { // const resourceName = resourceAlreadyExists(context); const { amplify } = context; const { amplifyMeta } = amplify.getProjectDetails(); - const dynamoDbResources = {}; + const dynamoDbResources: any = {}; - Object.keys(amplifyMeta[category]).forEach(resourceName => { + Object.keys(amplifyMeta[AmplifyCategories.STORAGE]).forEach(resourceName => { if ( - amplifyMeta[category][resourceName].service === serviceName && - amplifyMeta[category][resourceName].mobileHubMigrated !== true && - amplifyMeta[category][resourceName].serviceType !== 'imported' + amplifyMeta[AmplifyCategories.STORAGE][resourceName].service === serviceName && + amplifyMeta[AmplifyCategories.STORAGE][resourceName].mobileHubMigrated !== true && + amplifyMeta[AmplifyCategories.STORAGE][resourceName].serviceType !== 'imported' ) { - dynamoDbResources[resourceName] = amplifyMeta[category][resourceName]; + dynamoDbResources[resourceName] = amplifyMeta[AmplifyCategories.STORAGE][resourceName]; } }); - if (!amplifyMeta[category] || Object.keys(dynamoDbResources).length === 0) { + if (!amplifyMeta[AmplifyCategories.STORAGE] || Object.keys(dynamoDbResources).length === 0) { const errMessage = 'No resources to update. You need to add a resource.'; - context.print.error(errMessage); + printer.error(errMessage); context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); return; @@ -61,490 +93,430 @@ async function updateWalkthrough(context, defaultValuesFilename, serviceMetadata const answer = await inquirer.prompt(question); - return await configure(context, defaultValuesFilename, serviceMetadata, answer.resourceName); -} + const cliInputsState = new DynamoDBInputState(answer.resourceName); -async function configure(context, defaultValuesFilename, serviceMetadata, resourceName) { - const { amplify, print } = context; - const { inputs } = serviceMetadata; - const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); - const { getAllDefaults } = require(defaultValuesSrc); - - const defaultValues = getAllDefaults(amplify.getProjectDetails()); - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - - const attributeTypes = { - string: { code: 'S', indexable: true }, - number: { code: 'N', indexable: true }, - binary: { code: 'B', indexable: true }, - boolean: { code: 'BOOL', indexable: false }, - list: { code: 'L', indexable: false }, - map: { code: 'M', indexable: false }, - null: { code: 'NULL', indexable: false }, - 'string set': { code: 'SS', indexable: false }, - 'number set': { code: 'NS', indexable: false }, - 'binary set': { code: 'BS', indexable: false }, - }; - let usedAttributeDefinitions = new Set(); - let storageParams = {}; - - if (resourceName) { - const resourceDirPath = path.join(projectBackendDirPath, category, resourceName); - const parametersFilePath = path.join(resourceDirPath, parametersFileName); - let parameters; - - try { - parameters = context.amplify.readJsonFile(parametersFilePath); - } catch (e) { - parameters = {}; - } + const cliInputs = cliInputsState.getCliInputPayload(); - parameters.resourceName = resourceName; + let existingAttributeDefinitions: DynamoDBCLIInputsKeyType[] = []; - Object.assign(defaultValues, parameters); - - // Get storage question params - const storageParamsFilePath = path.join(resourceDirPath, storageParamsFileName); - - try { - storageParams = context.amplify.readJsonFile(storageParamsFilePath); - } catch (e) { - storageParams = {}; - } + if (cliInputs.partitionKey) { + existingAttributeDefinitions.push(cliInputs.partitionKey); } - - const resourceQuestions = [ - { - type: inputs[0].type, - name: inputs[0].key, - message: inputs[0].question, - validate: amplify.inputValidation(inputs[0]), - default: () => { - const defaultValue = defaultValues[inputs[0].key]; - return defaultValue; - }, - }, - { - type: inputs[1].type, - name: inputs[1].key, - message: inputs[1].question, - validate: amplify.inputValidation(inputs[1]), - default: answers => { - const defaultValue = defaultValues[inputs[1].key]; - return answers.resourceName || defaultValue; - }, - }, - ]; - - print.info(''); - print.info('Welcome to the NoSQL DynamoDB database wizard'); - print.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); - print.info(''); - - // Ask resource and table name question - - let answers = {}; - - if (!resourceName) { - answers = await inquirer.prompt(resourceQuestions); - } - - print.info(''); - print.info('You can now add columns to the table.'); - print.info(''); - - // Ask attribute questions - - const attributeQuestion = { - type: inputs[2].type, - name: inputs[2].key, - message: inputs[2].question, - validate: amplify.inputValidation(inputs[2]), - }; - const attributeTypeQuestion = { - type: inputs[3].type, - name: inputs[3].key, - message: inputs[3].question, - choices: Object.keys(attributeTypes), - }; - - let continueAttributeQuestion = true; - const attributeAnswers = []; - - if (resourceName) { - attributeAnswers.push( - { - AttributeName: defaultValues.partitionKeyName, - AttributeType: defaultValues.partitionKeyType, - }, - { - AttributeName: defaultValues.sortKeyName, - AttributeType: defaultValues.sortKeyType, - }, - ); - - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); + if (cliInputs.sortKey) { + existingAttributeDefinitions.push(cliInputs.sortKey); } - - const indexableAttributeList = await getExistingTableColumnNames(resourceName); - - while (continueAttributeQuestion) { - const attributeAnswer = await inquirer.prompt([attributeQuestion, attributeTypeQuestion]); - - if (attributeAnswers.findIndex(attribute => attribute.AttributeName === attributeAnswer[inputs[2].key]) !== -1) { - continueAttributeQuestion = await amplify.confirmPrompt('This attribute was already added. Do you want to add another attribute?'); - continue; - } - - attributeAnswers.push({ - AttributeName: attributeAnswer[inputs[2].key], - AttributeType: attributeTypes[attributeAnswer[inputs[3].key]].code, + if (cliInputs.gsi && cliInputs.gsi.length > 0) { + cliInputs.gsi.forEach((gsi: DynamoDBCLIInputsGSIType) => { + if (gsi.partitionKey) { + existingAttributeDefinitions.push(gsi.partitionKey); + } + if (gsi.sortKey) { + existingAttributeDefinitions.push(gsi.sortKey); + } }); - - if (attributeTypes[attributeAnswer[inputs[3].key]].indexable) { - indexableAttributeList.push(attributeAnswer[inputs[2].key]); - } - - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); } - answers.AttributeDefinitions = attributeAnswers; - - print.info(''); - print.info( - 'Before you create the database, you must specify how items in your table are uniquely organized. You do this by specifying a primary key. The primary key uniquely identifies each item in the table so that no two items can have the same key. This can be an individual column, or a combination that includes a primary key and a sort key.', - ); - print.info(''); - print.info('To learn more about primary keys, see:'); - print.info( - 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey', - ); - print.info(''); - // Ask for primary key - - answers.KeySchema = []; - let partitionKeyName; - let partitionKeyType; - - if (resourceName) { - ({ partitionKeyName } = defaultValues); - ({ partitionKeyType } = defaultValues); - } else { - const primaryKeyQuestion = { - type: inputs[4].type, - name: inputs[4].key, - message: inputs[4].question, - validate: amplify.inputValidation(inputs[3]), - choices: indexableAttributeList, - }; - - const partitionKeyAnswer = await inquirer.prompt([primaryKeyQuestion]); - - partitionKeyName = partitionKeyAnswer[inputs[4].key]; + if (!cliInputs.resourceName) { + throw new Error('resourceName not found in cli-inputs'); } - answers.KeySchema.push({ - AttributeName: partitionKeyName, - KeyType: 'HASH', - }); + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(context, existingAttributeDefinitions); - // Get the type for primary index + cliInputs.gsi = await askGSIQuestion(context, indexableAttributeList, attributeAnswers, cliInputs.gsi); + cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName, cliInputs.triggerFunctions); - const primaryAttrTypeIndex = answers.AttributeDefinitions.findIndex(attr => attr.AttributeName === partitionKeyName); + cliInputsState.saveCliInputPayload(cliInputs); - partitionKeyType = answers.AttributeDefinitions[primaryAttrTypeIndex].AttributeType; + const stackGenerator = new DDBStackTransform(cliInputs.resourceName); + stackGenerator.transform(); - usedAttributeDefinitions.add(partitionKeyName); + return cliInputs; +} - let sortKeyName; - let sortKeyType; +async function askTriggersQuestion(context: $TSContext, resourceName: string, existingTriggerFunctions?: string[]): Promise { + const { amplify } = context; + let triggerFunctions: string[] = existingTriggerFunctions || []; - if (resourceName) { - ({ sortKeyName } = defaultValues); + if (!existingTriggerFunctions || existingTriggerFunctions.length === 0) { + if (await amplify.confirmPrompt('Do you want to add a Lambda Trigger for your Table?', false)) { + let triggerName; + try { + // @ts-expect-error ts-migrate(2554) FIXME: Expected 3 arguments, but got 2. + triggerName = await addTrigger(context, resourceName); + return [triggerName]; + } catch (e) { + printer.error(e.message); + } + } + } else { + const triggerOperationQuestion = { + type: 'list', + name: 'triggerOperation', + message: 'Select from the following options', + choices: ['Add a Trigger', 'Remove a trigger', 'Skip Question'], + }; + let triggerName; + let continueWithTriggerOperationQuestion = true; - if (sortKeyName) { - answers.KeySchema.push({ - AttributeName: sortKeyName, - KeyType: 'RANGE', - }); + while (continueWithTriggerOperationQuestion) { + const triggerOperationAnswer = await inquirer.prompt([triggerOperationQuestion]); - usedAttributeDefinitions.add(sortKeyName); - } - } else if (await amplify.confirmPrompt('Do you want to add a sort key to your table?')) { - // Ask for sort key - if (answers.AttributeDefinitions.length > 1) { - const sortKeyQuestion = { - type: inputs[5].type, - name: inputs[5].key, - message: inputs[5].question, - choices: indexableAttributeList.filter(att => att !== partitionKeyName), - }; - const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); + switch (triggerOperationAnswer.triggerOperation) { + case 'Add a Trigger': { + try { + triggerName = await addTrigger(context, resourceName, triggerFunctions); + triggerFunctions.push(triggerName); + continueWithTriggerOperationQuestion = false; + } catch (e) { + printer.error(e.message); + continueWithTriggerOperationQuestion = true; + } + break; + } + case 'Remove a trigger': { + try { + if (triggerFunctions.length === 0) { + throw new Error('No triggers found associated with this table'); + } else { + triggerName = await removeTrigger(context, resourceName, triggerFunctions); - sortKeyName = sortKeyAnswer[inputs[5].key]; + const index = triggerFunctions.indexOf(triggerName); - answers.KeySchema.push({ - AttributeName: sortKeyName, - KeyType: 'RANGE', - }); + if (index >= 0) { + triggerFunctions.splice(index, 1); + continueWithTriggerOperationQuestion = false; + } else { + throw new Error('Could not find trigger function'); + } + } + } catch (e) { + printer.error(e.message); + continueWithTriggerOperationQuestion = true; + } - usedAttributeDefinitions.add(sortKeyName); - } else { - context.print.error('You must add additional keys in order to select a sort key.'); + break; + } + case 'Skip Question': { + continueWithTriggerOperationQuestion = false; + break; + } + default: + printer.error(`${triggerOperationAnswer.triggerOperation} not supported`); + } } } + return triggerFunctions; +} - if (sortKeyName) { - // Get the type for primary index - const sortKeyAttrTypeIndex = answers.AttributeDefinitions.findIndex(attr => attr.AttributeName === sortKeyName); - sortKeyType = answers.AttributeDefinitions[sortKeyAttrTypeIndex].AttributeType; - } - - answers.KeySchema = answers.KeySchema; - - print.info(''); - print.info( +async function askGSIQuestion( + context: $TSContext, + indexableAttributeList: string[], + attributeDefinitions: DynamoDBAttributeDefType[], + existingGSIList?: DynamoDBCLIInputsGSIType[], +) { + printer.info(''); + printer.info( 'You can optionally add global secondary indexes for this table. These are useful when you run queries defined in a different column than the primary key.', ); - print.info('To learn more about indexes, see:'); - print.info( + printer.info('To learn more about indexes, see:'); + printer.info( 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.SecondaryIndexes', ); - print.info(''); + printer.info(''); - // Ask for GSI's + const { amplify } = context; + let gsiList: DynamoDBCLIInputsGSIType[] = []; + + if ( + existingGSIList && + !!existingGSIList.length && + (await amplify.confirmPrompt('Do you want to keep existing global seconday indexes created on your table?')) + ) { + gsiList = existingGSIList; + } if (await amplify.confirmPrompt('Do you want to add global secondary indexes to your table?')) { let continuewithGSIQuestions = true; - const gsiList = []; while (continuewithGSIQuestions) { if (indexableAttributeList.length > 0) { const gsiAttributeQuestion = { - type: inputs[6].type, - name: inputs[6].key, - message: inputs[6].question, + type: 'input', + name: 'gsiName', + message: 'Please provide the GSI name:', + validate: amplify.inputValidation({ + validation: { + operator: 'regex', + value: '^[a-zA-Z0-9_-]+$', + onErrorMsg: 'You can use the following characters: a-z A-Z 0-9 - _', + }, + }), }; const gsiPrimaryKeyQuestion = { - type: inputs[7].type, - name: inputs[7].key, - message: inputs[7].question, - validate: amplify.inputValidation(inputs[3]), - choices: indexableAttributeList, + type: 'list', + name: 'gsiPartitionKey', + message: 'Please choose partition key for the GSI:', + choices: [...new Set(indexableAttributeList)], }; /*eslint-disable*/ const gsiPrimaryAnswer = await inquirer.prompt([gsiAttributeQuestion, gsiPrimaryKeyQuestion]); - const gsiPrimaryKeyName = gsiPrimaryAnswer[inputs[7].key]; + const gsiPrimaryKeyName = gsiPrimaryAnswer['gsiPartitionKey']; + const gsiPrimaryKeyIndex = attributeDefinitions.findIndex( + (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiPrimaryKeyName, + ); /* eslint-enable */ - const gsiItem = { - ProvisionedThroughput: { - ReadCapacityUnits: '5', - WriteCapacityUnits: '5', - }, - Projection: { - ProjectionType: 'ALL', + let gsiItem: DynamoDBCLIInputsGSIType = { + name: gsiPrimaryAnswer['gsiName'], + partitionKey: { + fieldName: gsiPrimaryKeyName, + fieldType: attributeDefinitions[gsiPrimaryKeyIndex].AttributeType, }, - IndexName: gsiPrimaryAnswer[inputs[6].key], - KeySchema: [ - { - AttributeName: gsiPrimaryKeyName, - KeyType: 'HASH', - }, - ], }; - usedAttributeDefinitions.add(gsiPrimaryKeyName); - - const sortKeyOptions = indexableAttributeList.filter(att => att !== gsiPrimaryKeyName); + const sortKeyOptions = indexableAttributeList.filter((att: string) => att !== gsiPrimaryKeyName); if (sortKeyOptions.length > 0) { if (await amplify.confirmPrompt('Do you want to add a sort key to your global secondary index?')) { const sortKeyQuestion = { - type: inputs[8].type, - name: inputs[8].key, - message: inputs[8].question, - choices: sortKeyOptions, + type: 'list', + name: 'gsiSortKey', + message: 'Please choose sort key for the GSI:', + choices: [...new Set(sortKeyOptions)], }; const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); - gsiItem.KeySchema.push({ - AttributeName: sortKeyAnswer[inputs[8].key], - KeyType: 'RANGE', - }); + const gsiSortKeyName = sortKeyAnswer['gsiSortKey']; + const gsiSortKeyIndex = attributeDefinitions.findIndex( + (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiSortKeyName, + ); - usedAttributeDefinitions.add(sortKeyAnswer[inputs[8].key]); + gsiItem.sortKey = { + fieldName: sortKeyAnswer['gsiSortKey'], + fieldType: attributeDefinitions[gsiSortKeyIndex].AttributeType, + }; } } gsiList.push(gsiItem); - continuewithGSIQuestions = await amplify.confirmPrompt('Do you want to add more global secondary indexes to your table?'); } else { - context.print.error('You do not have any other attributes remaining to configure'); + printer.error('You do not have any other attributes remaining to configure'); break; } } + } + return gsiList; +} - // if resource name is undefined then it's an 'add storage' we want to check on an update - if (resourceName) { - const existingGSIs = await getExistingStorageGSIs(resourceName); - const existingAttributeDefinitions = await getExistingStorageAttributeDefinitions(resourceName); - const allAttributeDefinitionsMap = new Map([ - ...existingAttributeDefinitions.map(r => [r.AttributeName, r]), - ...answers.AttributeDefinitions.map(r => [r.AttributeName, r]), - ]); - - if ( - !!existingGSIs.length && - (await amplify.confirmPrompt('Do you want to keep existing global seconday indexes created on your table?')) - ) { - existingGSIs.forEach(r => gsiList.push(r)); - answers.AttributeDefinitions = [...allAttributeDefinitionsMap.values()]; - - usedAttributeDefinitions = existingGSIs.reduce((prev, current) => { - current.KeySchema.map(r => prev.add(r.AttributeName)); - return prev; - }, usedAttributeDefinitions); - } - } +async function askSortKeyQuestion( + context: $TSContext, + indexableAttributeList: string[], + attributeDefinitions: DynamoDBAttributeDefType[], + partitionKeyFieldName: string, +): Promise { + const { amplify } = context; + + if (await amplify.confirmPrompt('Do you want to add a sort key to your table?')) { + // Ask for sort key + if (attributeDefinitions.length > 1) { + const sortKeyQuestion = { + type: 'list', + name: 'sortKey', + message: 'Please choose sort key for the table:', + choices: indexableAttributeList.filter((att: string) => att !== partitionKeyFieldName), + }; + + const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); + + const sortKeyName = sortKeyAnswer['sortKey']; + const sortKeyAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === sortKeyName); - if (gsiList.length > 0) { - answers.GlobalSecondaryIndexes = gsiList; + return { + fieldName: sortKeyName, + fieldType: attributeDefinitions[sortKeyAttrTypeIndex].AttributeType, + }; + } else { + printer.error('You must add additional keys in order to select a sort key.'); } } + return; +} - usedAttributeDefinitions = Array.from(usedAttributeDefinitions); - - /* Filter out only attribute - * definitions which have been used - cfn errors out otherwise */ - answers.AttributeDefinitions = answers.AttributeDefinitions.filter( - attributeDefinition => usedAttributeDefinitions.indexOf(attributeDefinition.AttributeName) !== -1, +async function askPrimaryKeyQuestion(indexableAttributeList: string[], attributeDefinitions: DynamoDBAttributeDefType[]) { + printer.info(''); + printer.info( + 'Before you create the database, you must specify how items in your table are uniquely organized. You do this by specifying a primary key. The primary key uniquely identifies each item in the table so that no two items can have the same key. This can be an individual column, or a combination that includes a primary key and a sort key.', ); + printer.info(''); + printer.info('To learn more about primary keys, see:'); + printer.info( + 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey', + ); + printer.info(''); - Object.assign(defaultValues, answers); - - // Ask Lambda trigger question - if (!storageParams || !storageParams.triggerFunctions || storageParams.triggerFunctions.length === 0) { - if (await amplify.confirmPrompt('Do you want to add a Lambda Trigger for your Table?', false)) { - let triggerName; + const primaryKeyQuestion = { + type: 'list', + name: 'partitionKey', + message: 'Please choose partition key for the table:', + choices: indexableAttributeList, + }; - try { - triggerName = await addTrigger(context, defaultValues.resourceName); + const partitionKeyAnswer = await inquirer.prompt([primaryKeyQuestion]); - if (!storageParams) { - storageParams = {}; - } + const partitionKeyName = partitionKeyAnswer['partitionKey']; + const primaryAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === partitionKeyName); - storageParams.triggerFunctions = [triggerName]; - } catch (e) { - context.print.error(e.message); - } - } - } else { - const triggerOperationQuestion = { - type: 'list', - name: 'triggerOperation', - message: 'Select from the following options', - choices: ['Add a Trigger', 'Remove a trigger', 'Skip Question'], - }; - let triggerName; - let continueWithTriggerOperationQuestion = true; + return { + fieldName: partitionKeyName, + fieldType: attributeDefinitions[primaryAttrTypeIndex].AttributeType, + }; +} - while (continueWithTriggerOperationQuestion) { - const triggerOperationAnswer = await inquirer.prompt([triggerOperationQuestion]); +async function askAttributeListQuestion(context: $TSContext, existingAttributeDefinitions?: DynamoDBCLIInputsKeyType[]) { + const attributeTypes = { + string: { code: 'string', indexable: true }, + number: { code: 'number', indexable: true }, + binary: { code: 'binary', indexable: true }, + boolean: { code: 'boolean', indexable: false }, + list: { code: 'list', indexable: false }, + map: { code: 'map', indexable: false }, + null: { code: 'null', indexable: false }, + 'string-set': { code: 'string-set', indexable: false }, + 'number-set': { code: 'number-set', indexable: false }, + 'binary-set': { code: 'binary-set', indexable: false }, + }; - switch (triggerOperationAnswer.triggerOperation) { - case 'Add a Trigger': { - try { - triggerName = await addTrigger(context, defaultValues.resourceName, storageParams.triggerFunctions); - if (!storageParams) { - storageParams = {}; - } else if (!storageParams.triggerFunctions) { - storageParams.triggerFunctions = [triggerName]; - } else { - storageParams.triggerFunctions.push(triggerName); - } + printer.info(''); + printer.info('You can now add columns to the table.'); + printer.info(''); - continueWithTriggerOperationQuestion = false; - } catch (e) { - context.print.error(e.message); - continueWithTriggerOperationQuestion = true; - } - break; - } - case 'Remove a trigger': { - try { - if (!storageParams || !storageParams.triggerFunctions || storageParams.triggerFunctions.length === 0) { - throw new Error('No triggers found associated with this table'); - } else { - triggerName = await removeTrigger(context, defaultValues.resourceName, storageParams.triggerFunctions); + const QUESTION_KEY = 'attribute'; + const { amplify } = context; - const index = storageParams.triggerFunctions.indexOf(triggerName); + const attributeQuestion = { + type: 'input', + name: QUESTION_KEY, + message: 'What would you like to name this column:', + validate: amplify.inputValidation({ + validation: { + operator: 'regex', + value: '^[a-zA-Z0-9_-]+$', + onErrorMsg: "'You can use the following characters: a-z A-Z 0-9 - _'", + }, + }), + }; - if (index >= 0) { - storageParams.triggerFunctions.splice(index, 1); - continueWithTriggerOperationQuestion = false; - } else { - throw new Error('Could not find trigger function'); - } - } - } catch (e) { - context.print.error(e.message); - continueWithTriggerOperationQuestion = true; - } + const attributeTypeQuestion = { + type: 'list', + name: 'attributeType', + message: 'Please choose the data type:', + choices: Object.keys(attributeTypes), + }; - break; - } - case 'Skip Question': { - continueWithTriggerOperationQuestion = false; - break; - } - default: - context.print.error(`${triggerOperationAnswer.triggerOperation} not supported`); - } - } + let continueAttributeQuestion = true; + let attributeAnswers: DynamoDBAttributeDefType[] = []; + let indexableAttributeList: string[] = []; + let existingAttributes: DynamoDBAttributeDefType[] = []; + + if (existingAttributeDefinitions) { + existingAttributes = existingAttributeDefinitions.map((attr: DynamoDBCLIInputsKeyType) => { + return { + AttributeName: attr.fieldName, + AttributeType: attr.fieldType, + }; + }); } - const resource = defaultValues.resourceName; - const resourceDirPath = path.join(projectBackendDirPath, category, resource); + if (existingAttributes.length > 0) { + attributeAnswers = existingAttributes; + indexableAttributeList = attributeAnswers.map((attr: DynamoDBAttributeDefType) => attr.AttributeName); + continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); + } - delete defaultValues.resourceName; + while (continueAttributeQuestion) { + const attributeAnswer = await inquirer.prompt([attributeQuestion, attributeTypeQuestion]); - fs.ensureDirSync(resourceDirPath); + if ( + attributeAnswers.findIndex((attribute: DynamoDBAttributeDefType) => attribute.AttributeName === attributeAnswer[QUESTION_KEY]) !== -1 + ) { + continueAttributeQuestion = await amplify.confirmPrompt('This attribute was already added. Do you want to add another attribute?'); + continue; + } - const parametersFilePath = path.join(resourceDirPath, parametersFileName); + attributeAnswers.push({ + AttributeName: attributeAnswer[QUESTION_KEY], + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + AttributeType: attributeTypes[attributeAnswer['attributeType']].code, + }); - // Copy just the table name as parameters - const parameters = { - tableName: defaultValues.tableName, - partitionKeyName, - partitionKeyType, - }; + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + if (attributeTypes[attributeAnswer['attributeType']].indexable) { + indexableAttributeList.push(attributeAnswer[QUESTION_KEY]); + } - if (sortKeyName) { - Object.assign(parameters, { sortKeyName, sortKeyType }); + continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); } - let jsonString = JSON.stringify(parameters, null, 4); + return { attributeAnswers, indexableAttributeList }; +} - fs.writeFileSync(parametersFilePath, jsonString, 'utf8'); +async function askTableNameQuestion(context: $TSContext, defaultValues: any, resourceName: string) { + const { amplify } = context; - const storageParamsFilePath = path.join(resourceDirPath, storageParamsFileName); + const question = [ + { + type: 'input', + name: 'tableName', + message: 'Please provide table name:', + validate: amplify.inputValidation({ + validation: { + operator: 'regex', + value: '^[a-zA-Z0-9._-]+$', + onErrorMsg: 'You can use the following characters: a-z A-Z 0-9 . - _', + }, + }), + default: (answers: any) => { + const defaultValue = defaultValues['tableName']; + return resourceName || defaultValue; + }, + }, + ]; - jsonString = JSON.stringify(storageParams, null, 4); + const answer = await inquirer.prompt(question); - fs.writeFileSync(storageParamsFilePath, jsonString, 'utf8'); + return answer.tableName; +} - await copyCfnTemplate(context, resource, defaultValues); +async function askResourceNameQuestion(context: $TSContext, defaultValues: any): Promise { + const { amplify } = context; - return resource; + const question = [ + { + type: 'input', + name: 'resourceName', + message: 'Please provide a friendly name for your resource that will be used to label this category in the project:', + validate: amplify.inputValidation({ + validation: { + operator: 'regex', + value: '^[a-zA-Z0-9]+$', + onErrorMsg: 'Resource name should be alphanumeric', + }, + }), + default: () => { + const defaultValue = defaultValues['resourceName']; + return defaultValue; + }, + }, + ]; + const answer = await inquirer.prompt(question); + return answer.resourceName; } -async function removeTrigger(context, resourceName, triggerList) { +async function removeTrigger(context: any, resourceName: string, triggerList: string[]) { const triggerOptionQuestion = { type: 'list', name: 'triggerOption', @@ -573,7 +545,7 @@ async function removeTrigger(context, resourceName, triggerList) { return functionName; } -async function addTrigger(context, resourceName, triggerList) { +async function addTrigger(context: any, resourceName: string, triggerList: string[]) { const triggerTypeQuestion = { type: 'list', name: 'triggerType', @@ -587,9 +559,9 @@ async function addTrigger(context, resourceName, triggerList) { let lambdaResources = await getLambdaFunctions(context); if (triggerList) { - const filteredLambdaResources = []; + const filteredLambdaResources: string[] = []; - lambdaResources.forEach(lambdaResource => { + lambdaResources.forEach((lambdaResource: string) => { if (triggerList.indexOf(lambdaResource) === -1) { filteredLambdaResources.push(lambdaResource); } @@ -663,7 +635,7 @@ async function addTrigger(context, resourceName, triggerList) { context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); - context.print.success(`Successfully added resource ${functionName} locally`); + printer.success(`Successfully added resource ${functionName} locally`); } const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); @@ -742,7 +714,7 @@ async function addTrigger(context, resourceName, triggerList) { const resourceDependsOn = amplifyMeta.function[functionName].dependsOn || []; let resourceExists = false; - resourceDependsOn.forEach(resource => { + resourceDependsOn.forEach((resource: any) => { if (resource.resourceName === resourceName) { resourceExists = true; resourceDependsOn.attributes = ['Name', 'Arn', 'StreamArn']; @@ -751,7 +723,7 @@ async function addTrigger(context, resourceName, triggerList) { if (!resourceExists) { resourceDependsOn.push({ - category: 'storage', + category: AmplifyCategories.STORAGE, resourceName, attributes: ['Name', 'Arn', 'StreamArn'], }); @@ -763,7 +735,7 @@ async function addTrigger(context, resourceName, triggerList) { fs.writeFileSync(functionCFNFilePath, functionCFNString, 'utf8'); context.amplify.updateamplifyMetaAfterResourceUpdate('function', functionName, 'dependsOn', resourceDependsOn); - context.print.success(`Successfully updated resource ${functionName} locally`); + printer.success(`Successfully updated resource ${functionName} locally`); if (await context.amplify.confirmPrompt(`Do you want to edit the local ${functionName} lambda function now?`)) { await context.amplify.openEditor(context, `${projectBackendDirPath}/function/${functionName}/src/index.js`); @@ -775,35 +747,21 @@ async function addTrigger(context, resourceName, triggerList) { return functionName; } -async function getLambdaFunctions(context) { +async function getLambdaFunctions(context: any) { const { allResources } = await context.amplify.getResourceStatus(); const lambdaResources = allResources - .filter(resource => resource.service === FunctionServiceNameLambdaFunction) - .map(resource => resource.resourceName); + .filter((resource: any) => resource.service === FunctionServiceNameLambdaFunction) + .map((resource: any) => resource.resourceName); return lambdaResources; } -function copyCfnTemplate(context, resourceName, options) { - const pluginDir = __dirname; - const copyJobs = [ - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'cloudformation-templates', templateFileName), - target: getCloudFormationTemplatePath(resourceName), - }, - ]; - - // copy over the files - return context.amplify.copyBatch(context, copyJobs, options); -} - -function migrate(context, projectPath, resourceName) { - const resourceDirPath = path.join(projectPath, 'amplify', 'backend', category, resourceName); +function migrateCategory(context: any, projectPath: any, resourceName: any) { + const resourceDirPath = path.join(projectPath, 'amplify', 'backend', AmplifyCategories.STORAGE, resourceName); const cfnFilePath = path.join(resourceDirPath, `${resourceName}-cloudformation-template.json`); // Removes dangling commas from a JSON - const removeDanglingCommas = value => { + const removeDanglingCommas = (value: any) => { const regex = /,(?!\s*?[{["'\w])/g; return value.replace(regex, ''); @@ -822,20 +780,20 @@ function migrate(context, projectPath, resourceName) { Object.assign(newCfn, oldCfn); // Add env parameter - if (!newCfn.Parameters) { - newCfn.Parameters = {}; + if (!(newCfn as any).Parameters) { + (newCfn as any).Parameters = {}; } - newCfn.Parameters.env = { + (newCfn as any).Parameters.env = { Type: 'String', }; // Add conditions block - if (!newCfn.Conditions) { - newCfn.Conditions = {}; + if (!(newCfn as any).Conditions) { + (newCfn as any).Conditions = {}; } - newCfn.Conditions.ShouldNotCreateEnvResources = { + (newCfn as any).Conditions.ShouldNotCreateEnvResources = { 'Fn::Equals': [ { Ref: 'env', @@ -845,8 +803,7 @@ function migrate(context, projectPath, resourceName) { }; // Add if condition for resource name change - - newCfn.Resources.DynamoDBTable.Properties.TableName = { + (newCfn as any).Resources.DynamoDBTable.Properties.TableName = { 'Fn::If': [ 'ShouldNotCreateEnvResources', { @@ -874,11 +831,11 @@ function migrate(context, projectPath, resourceName) { fs.writeFileSync(cfnFilePath, jsonString, 'utf8'); } -function getIAMPolicies(resourceName, crudOptions) { +function getIAMPolicies(resourceName: any, crudOptions: any) { let policy = {}; - const actions = []; + const actions: any = []; - crudOptions.forEach(crudOption => { + crudOptions.forEach((crudOption: any) => { switch (crudOption) { case 'create': actions.push('dynamodb:Put*', 'dynamodb:Create*', 'dynamodb:BatchWriteItem'); @@ -903,13 +860,13 @@ function getIAMPolicies(resourceName, crudOptions) { Resource: crudOptions.customPolicyResource ? crudOptions.customPolicyResource : [ - { Ref: `${category}${resourceName}Arn` }, + { Ref: `${AmplifyCategories.STORAGE}${resourceName}Arn` }, { 'Fn::Join': [ '/', [ { - Ref: `${category}${resourceName}Arn`, + Ref: `${AmplifyCategories.STORAGE}${resourceName}Arn`, }, 'index/*', ], @@ -926,6 +883,6 @@ function getIAMPolicies(resourceName, crudOptions) { module.exports = { addWalkthrough, updateWalkthrough, - migrate, + migrate: migrateCategory, getIAMPolicies, }; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts new file mode 100644 index 00000000000..be2288064ca --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts @@ -0,0 +1,162 @@ +import { S3AccessType, S3PermissionType, S3UserInputs } from '../service-walkthrough-types/s3-user-input-types'; +import { AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; +import { JSONUtilities, pathManager } from 'amplify-cli-core'; +import { CLIInputSchemaValidator } from 'amplify-cli-core'; + +import * as fs from 'fs-extra'; +import * as path from 'path'; + +type ResourcRefType = { + Ref: string; +}; + +export enum S3CFNPermissionType { + PUT_OBJECT = 's3:PutObject', + GET_OBJECT = 's3:GetObject', + DELETE_OBJECT = 's3:DeleteObject', + LIST_BUCKET = 's3:ListBucket', +} + +export type S3CFNDependsOn = { + category: string; + resourceName: string; + attributes: string[]; +}; + +export type S3CLIWalkthroughParams = { + resourceName: string; + bucketName: string; + authPolicyName: string; + unauthPolicyName: string; + authRoleName: ResourcRefType; + unauthRoleName: ResourcRefType; + storageAccess: S3AccessType; + selectedGuestPermissions: S3CFNPermissionType[]; + selectedAuthenticatedPermissions: S3CFNPermissionType[]; + s3PermissionsAuthenticatedPublic: string; + s3PublicPolicy: string; + s3PermissionsAuthenticatedUploads: string; + s3UploadsPolicy: string; + s3PermissionsAuthenticatedProtected: string; + s3ProtectedPolicy: string; + s3PermissionsAuthenticatedPrivate: string; + s3PrivatePolicy: string; + AuthenticatedAllowList: string; + s3ReadPolicy: string; + s3PermissionsGuestPublic: string; + s3PermissionsGuestUploads: string; + GuestAllowList: string; + triggerFunction: string; + service: string; + providerPlugin: string; + dependsOn: S3CFNDependsOn[]; +}; + +export type S3InputStateOptions = { + userInputDataFilename: string; //cli-inputs.json + resourceName: string; + inputPayload?: S3UserInputs; +}; + +export class S3InputState { + static s3InputState: S3InputState; + _cliInputsFilePath: string; //cli-inputs.json (output) filepath + _resourceName: string; //user friendly name provided by user + _category: string; //category of the resource + _service: string; //AWS service for the resource + _inputPayload: S3UserInputs | undefined; //S3 options selected by user + + constructor(props: S3InputStateOptions) { + this._category = AmplifyCategories.STORAGE; + this._service = AmplifySupportedService.S3; + this._cliInputsFilePath = props.userInputDataFilename; + this._resourceName = props.resourceName; + + // Read cliInputs file if exists + try { + this._inputPayload = props.inputPayload ?? JSONUtilities.readJson(this._cliInputsFilePath, { throwIfNotExist: true }); + } catch (e) { + throw new Error('migrate project with command : amplify migrate '); + } + + // validate cli-inputs.json + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'S3UserInputs'); + schemaValidator.validateInput(JSON.stringify(this._inputPayload!)); + } + + public static getPermissionTypeFromCfnType(s3pPrmissionCfnType: S3CFNPermissionType): S3PermissionType { + switch (s3pPrmissionCfnType) { + case S3CFNPermissionType.PUT_OBJECT: + return S3PermissionType.PUT_OBJECT; + case S3CFNPermissionType.GET_OBJECT: + return S3PermissionType.GET_OBJECT; + case S3CFNPermissionType.DELETE_OBJECT: + return S3PermissionType.DELETE_OBJECT; + case S3CFNPermissionType.LIST_BUCKET: + return S3PermissionType.LIST_BUCKET; + default: + throw new Error(`Unknown CFN Type: ${s3pPrmissionCfnType}`); + } + } + + public static getInputPermissionsFromCfnPermissions(selectedGuestPermissions: S3CFNPermissionType[] | undefined) { + if (selectedGuestPermissions) { + return selectedGuestPermissions.map(S3InputState.getPermissionTypeFromCfnType); + } else { + return []; + } + } + + public static cliWalkThroughToCliInputParams(cliInputsFilePath: string, options: S3CLIWalkthroughParams) { + const inputProps: S3InputStateOptions = { + userInputDataFilename: cliInputsFilePath, + resourceName: options.resourceName, + inputPayload: { + resourceName: options.resourceName, + bucketName: options.bucketName, + storageAccess: options.storageAccess, + selectedGuestPermissions: S3InputState.getInputPermissionsFromCfnPermissions(options.selectedGuestPermissions), + selectedAuthenticatedPermissions: S3InputState.getInputPermissionsFromCfnPermissions(options.selectedAuthenticatedPermissions), + isTriggerEnabled: options.triggerFunction !== 'NONE' ? true : false, //enable if trigger + triggerFunctionName: options.triggerFunction !== 'NONE' ? options.triggerFunction : undefined, + }, + }; + return inputProps; + } + + updateInputPayload(props: S3InputStateOptions) { + // Overwrite + this._inputPayload = props.inputPayload; + + // validate cli-inputs.json + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'S3UserInputs'); + schemaValidator.validateInput(JSON.stringify(this._inputPayload!)); + } + + public static getInstance(props: S3InputStateOptions): S3InputState { + const projectPath = pathManager.findProjectRoot(); + if (!S3InputState.s3InputState) { + S3InputState.s3InputState = new S3InputState(props); + } + S3InputState.s3InputState.updateInputPayload(props); + return S3InputState.s3InputState; + } + + public getCliInputPayload(): S3UserInputs { + if (this._inputPayload) { + return this._inputPayload; + } else { + throw new Error('cli-inputs not present. Either add category or migrate project to support extensibility'); + } + } + + public saveCliInputPayload(): void { + const backend = pathManager.getBackendDirPath(); + fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); + try { + JSONUtilities.writeJson(this._cliInputsFilePath, this._inputPayload); + } catch (e) { + throw new Error(e); + } + } +} diff --git a/packages/amplify-category-storage/tsconfig.json b/packages/amplify-category-storage/tsconfig.json index 6d6385a3654..4fc10f8059f 100644 --- a/packages/amplify-category-storage/tsconfig.json +++ b/packages/amplify-category-storage/tsconfig.json @@ -11,8 +11,8 @@ "resources", ], "references": [ - { "path": "../amplify-cli-core" }, + {"path": "../amplify-cli-core"}, { "path": "../amplify-prompts" }, - { "path": "../amplify-util-import" } + {"path": "../amplify-util-import"}, ] } diff --git a/packages/amplify-cli-core/src/cliConstants.ts b/packages/amplify-cli-core/src/cliConstants.ts index e60538dfdac..ca60614aac3 100644 --- a/packages/amplify-cli-core/src/cliConstants.ts +++ b/packages/amplify-cli-core/src/cliConstants.ts @@ -26,7 +26,7 @@ export const AmplifySupportedService = { COGNITO: 'Cognito', }; -export const overriddenCategories = [AmplifyCategories.AUTH]; +export const overriddenCategories = [AmplifyCategories.AUTH, AmplifyCategories.STORAGE]; export type IAmplifyResource = { category: string; diff --git a/packages/amplify-cli-core/src/feature-flags/featureFlags.ts b/packages/amplify-cli-core/src/feature-flags/featureFlags.ts index 8e6655a7b40..9a802f35fa4 100644 --- a/packages/amplify-cli-core/src/feature-flags/featureFlags.ts +++ b/packages/amplify-cli-core/src/feature-flags/featureFlags.ts @@ -702,6 +702,12 @@ export class FeatureFlags { // FF for overrides this.registerFlag('overrides', [ + { + name: 'storage', + type: 'boolean', + defaultValueForExistingProjects: false, + defaultValueForNewProjects: true, + }, { name: 'auth', type: 'boolean', diff --git a/packages/amplify-cli-core/src/state-manager/pathManager.ts b/packages/amplify-cli-core/src/state-manager/pathManager.ts index 268aad89753..00a8d9d0716 100644 --- a/packages/amplify-cli-core/src/state-manager/pathManager.ts +++ b/packages/amplify-cli-core/src/state-manager/pathManager.ts @@ -53,6 +53,7 @@ export const PathConstants = { CLIJsonWithEnvironmentFileName: (env: string) => `cli.${env}.json`, CfnFileName: (resourceName: string) => `${resourceName}-awscloudformation-template.json`, + cliInputsFileName: 'cli-inputs.json', }; export class PathManager { @@ -89,6 +90,16 @@ export class PathManager { getBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.BackendDirName]); + getCliInputsPath = (projectPath: string, category: string, resourceName: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.BackendDirName, + category, + resourceName, + PathConstants.cliInputsFileName, + ]); + }; + getCurrentCloudBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.CurrentCloudBackendDirName]); diff --git a/packages/amplify-cli/amplify-plugin.json b/packages/amplify-cli/amplify-plugin.json index 2849e512d89..dc90102dda9 100644 --- a/packages/amplify-cli/amplify-plugin.json +++ b/packages/amplify-cli/amplify-plugin.json @@ -19,7 +19,8 @@ "status", "uninstall", "upgrade", - "version" + "version", + "build-override" ], "commandAliases": { "h": "help", diff --git a/packages/amplify-cli/package.json b/packages/amplify-cli/package.json index c892a0c9b4e..22b0edb4790 100644 --- a/packages/amplify-cli/package.json +++ b/packages/amplify-cli/package.json @@ -44,7 +44,7 @@ "amplify-category-interactions": "2.6.5", "amplify-category-notifications": "2.19.4", "amplify-category-predictions": "2.9.12", - "amplify-category-storage": "2.12.9", + "@aws-amplify/amplify-category-storage": "1.0.0", "amplify-category-xr": "2.8.21", "amplify-cli-core": "1.29.0", "amplify-cli-logger": "1.1.0", @@ -211,7 +211,7 @@ "storage": { "name": "storage", "type": "category", - "packageName": "amplify-category-storage" + "packageName": "@aws-amplify/amplify-category-storage" }, "xr": { "name": "xr", From f1b30efda1cdb325db2d9ba901553fbe1f8a568b Mon Sep 17 00:00:00 2001 From: Ghosh Date: Thu, 30 Sep 2021 21:13:39 -0700 Subject: [PATCH 2/7] feat: add migration logic --- .../resources/genInputSchema.ts | 4 +- .../DynamoDB}/DynamoDBCLIInputs.schema.json | 0 .../schemas/S3}/S3UserInputs.schema.json | 0 .../src/commands/storage/override.ts | 19 +++ .../amplify-category-storage/src/index.ts | 16 ++- .../dynamoDB-input-state.ts | 108 +++++++++++++++++- .../dynamoDb-walkthrough.ts | 14 +++ 7 files changed, 151 insertions(+), 10 deletions(-) rename packages/amplify-category-storage/{src/provider-utils/awscloudformation/schemas/dynamoDB => resources/schemas/DynamoDB}/DynamoDBCLIInputs.schema.json (100%) rename packages/amplify-category-storage/{src/provider-utils/awscloudformation/schemas/s3 => resources/schemas/S3}/S3UserInputs.schema.json (100%) diff --git a/packages/amplify-category-storage/resources/genInputSchema.ts b/packages/amplify-category-storage/resources/genInputSchema.ts index b60d8436d55..20e8eb40016 100644 --- a/packages/amplify-category-storage/resources/genInputSchema.ts +++ b/packages/amplify-category-storage/resources/genInputSchema.ts @@ -3,11 +3,11 @@ import { TypeDef, CLIInputSchemaGenerator } from 'amplify-cli-core'; //ResourceProvider TypeDefs const DDBStorageTypeDef: TypeDef = { typeName: 'DynamoDBCLIInputs', - service: 'dynamoDB', + service: 'DynamoDB', }; const S3StorageTypeDef: TypeDef = { typeName: 'S3UserInputs', - service: 's3', + service: 'S3', }; // Defines the type names and the paths to the TS files that define them diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json b/packages/amplify-category-storage/resources/schemas/DynamoDB/DynamoDBCLIInputs.schema.json similarity index 100% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/dynamoDB/DynamoDBCLIInputs.schema.json rename to packages/amplify-category-storage/resources/schemas/DynamoDB/DynamoDBCLIInputs.schema.json diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json b/packages/amplify-category-storage/resources/schemas/S3/S3UserInputs.schema.json similarity index 100% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/schemas/s3/S3UserInputs.schema.json rename to packages/amplify-category-storage/resources/schemas/S3/S3UserInputs.schema.json diff --git a/packages/amplify-category-storage/src/commands/storage/override.ts b/packages/amplify-category-storage/src/commands/storage/override.ts index 18d6cd6a2bf..ebfe4769950 100644 --- a/packages/amplify-category-storage/src/commands/storage/override.ts +++ b/packages/amplify-category-storage/src/commands/storage/override.ts @@ -7,6 +7,8 @@ import { generateOverrideSkeleton, $TSContext, FeatureFlags } from 'amplify-cli- import { printer } from 'amplify-prompts'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; +import { DynamoDBInputState } from '../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; +import { DDBStackTransform } from '../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; const category = 'storage'; export const name = 'override'; @@ -53,6 +55,23 @@ export const run = async (context: $TSContext) => { 'overrides-resource', amplifyMeta[category][selectedResource].service, ); + + // Make sure to migrate first + if (amplifyMeta[category][selectedResource].service === 'DynamoDB') { + const resourceInputState = new DynamoDBInputState(selectedResource); + if (!resourceInputState.cliInputFileExists()) { + if (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) { + resourceInputState.migrate(); + const stackGenerator = new DDBStackTransform(selectedResource); + stackGenerator.transform(); + } else { + return; + } + } + } else if (amplifyMeta[category][selectedResource].service === 'S3') { + // S3 migration logic goes in here + } + await generateOverrideSkeleton(context, srcPath, destPath); } else { printer.info('Storage overrides is currently not turned on. In amplify/cli.json file please include the following:'); diff --git a/packages/amplify-category-storage/src/index.ts b/packages/amplify-category-storage/src/index.ts index 9f38de2d70a..274a986f67c 100644 --- a/packages/amplify-category-storage/src/index.ts +++ b/packages/amplify-category-storage/src/index.ts @@ -1,8 +1,9 @@ import * as path from 'path'; const sequential = require('promise-sequential'); import { updateConfigOnEnvInit } from './provider-utils/awscloudformation'; -import { $TSContext, AmplifyCategories, IAmplifyResource } from 'amplify-cli-core'; +import { $TSContext, AmplifyCategories, IAmplifyResource, pathManager } from 'amplify-cli-core'; import { DDBStackTransform } from './provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import { DynamoDBInputState } from './provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; export { AmplifyDDBResourceTemplate } from './provider-utils/awscloudformation/cdk-stack-builder/types'; async function add(context: any, providerName: any, service: any) { @@ -60,13 +61,20 @@ async function migrateStorageCategory(context: any) { async function transformCategoryStack(context: $TSContext, resource: IAmplifyResource) { if (resource.service === 'DynamoDB') { - const stackGenerator = new DDBStackTransform(resource.resourceName); - stackGenerator.transform(); - } else if (resource.service === 'DynamoDB') { + if (canResourceBeTransformed(resource.resourceName)) { + const stackGenerator = new DDBStackTransform(resource.resourceName); + stackGenerator.transform(); + } + } else if (resource.service === 'S3') { // Not yet implemented } } +function canResourceBeTransformed(resourceName: string) { + const resourceInputState = new DynamoDBInputState(resourceName); + return resourceInputState.cliInputFileExists(); +} + async function getPermissionPolicies(context: any, resourceOpsMapping: any) { const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts index 5bb7b6692e8..8bc0d845719 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts @@ -1,4 +1,4 @@ -import { DynamoDBCLIInputs } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { DynamoDBCLIInputs, DynamoDBCLIInputsGSIType } from '../service-walkthrough-types/dynamoDB-user-input-types'; import { AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; import { JSONUtilities, pathManager } from 'amplify-cli-core'; import { CLIInputSchemaValidator } from 'amplify-cli-core'; @@ -26,23 +26,123 @@ export class DynamoDBInputState { public getCliInputPayload(): DynamoDBCLIInputs { let cliInputs: DynamoDBCLIInputs; + // Read cliInputs file if exists try { cliInputs = JSON.parse(fs.readFileSync(this._cliInputsFilePath, 'utf8')); } catch (e) { - throw new Error('migrate project with command : amplify migrate '); + throw new Error('cli-inputs.json file missing from the resource directory'); } return cliInputs; } + public migrate() { + let cliInputs: DynamoDBCLIInputs; + const attrReverseMap: any = { + S: 'string', + N: 'number', + B: 'binary', + BOOL: 'boolean', + L: 'list', + M: 'map', + NULL: null, + SS: 'string-set', + NS: 'number-set', + BS: 'binary-set', + }; + + // migrate the resource to new directory structure if cli-inputs.json is not found for the resource + + const backendDir = pathManager.getBackendDirPath(); + const oldParametersFilepath = path.join(backendDir, 'storage', this._resourceName, 'parameters.json'); + const oldCFNFilepath = path.join(backendDir, 'storage', this._resourceName, `${this._resourceName}-cloudformation-template.json`); + const oldStorageParamsFilepath = path.join(backendDir, 'storage', this._resourceName, `storage-params.json`); + + const oldParameters: any = JSONUtilities.readJson(oldParametersFilepath, { throwIfNotExist: true }); + const oldCFN: any = JSONUtilities.readJson(oldCFNFilepath, { throwIfNotExist: true }); + const oldStorageParams: any = JSONUtilities.readJson(oldStorageParamsFilepath, { throwIfNotExist: false }) || {}; + + const partitionKey = { + fieldName: oldParameters.partitionKeyName, + fieldType: attrReverseMap[oldParameters.partitionKeyType], + }; + + let sortKey; + + if (oldParameters.sortKeyName) { + sortKey = { + fieldName: oldParameters.sortKeyName, + fieldType: attrReverseMap[oldParameters.sortKeyType], + }; + } + + let triggerFunctions = []; + + if (oldStorageParams.triggerFunctions) { + triggerFunctions = oldStorageParams.triggerFunctions; + } + + const getType = (attrList: any, attrName: string) => { + let attrType; + + attrList.forEach((attr: any) => { + if (attr.AttributeName === attrName) { + attrType = attrReverseMap[attr.AttributeType]; + } + }); + + return attrType; + }; + + let gsi: DynamoDBCLIInputsGSIType[] = []; + + if (oldCFN?.Resources?.DynamoDBTable?.Properties?.GlobalSecondaryIndexes) { + oldCFN.Resources.DynamoDBTable.Properties.GlobalSecondaryIndexes.forEach((cfnGSIValue: any) => { + let gsiValue: any = {}; + (gsiValue.name = cfnGSIValue.IndexName), + cfnGSIValue.KeySchema.forEach((keySchema: any) => { + if (keySchema.KeyType === 'HASH') { + gsiValue.partitionKey = { + fieldName: keySchema.AttributeName, + fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName), + }; + } else { + gsiValue.sortKey = { + fieldName: keySchema.AttributeName, + fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName), + }; + } + }); + gsi.push(gsiValue); + }); + } + cliInputs = { + resourceName: this._resourceName, + tableName: oldParameters.tableName, + partitionKey, + sortKey, + triggerFunctions, + gsi, + }; + + this.saveCliInputPayload(cliInputs); + } + + public cliInputFileExists(): boolean { + if (fs.existsSync(this._cliInputsFilePath)) { + return true; + } + return false; + } + public isCLIInputsValid(cliInputs?: DynamoDBCLIInputs) { if (!cliInputs) { cliInputs = this.getCliInputPayload(); } - /*const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, "DynamoDBCLIInputs" ); - schemaValidator.validateInput(JSON.stringify(cliInputs));*/ + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'DynamoDBCLIInputs'); + schemaValidator.validateInput(JSON.stringify(cliInputs)); } public saveCliInputPayload(cliInputs: DynamoDBCLIInputs): void { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts index fcd6a596cda..e1cfd8cfccd 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -93,8 +93,22 @@ async function updateWalkthrough(context: $TSContext) { const answer = await inquirer.prompt(question); + // Check if we need to migrate to cli-inputs.json const cliInputsState = new DynamoDBInputState(answer.resourceName); + if (!cliInputsState.cliInputFileExists()) { + if ( + context.exeInfo?.forcePush || + (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) + ) { + cliInputsState.migrate(); + const stackGenerator = new DDBStackTransform(answer.resourceName); + stackGenerator.transform(); + } else { + return; + } + } + const cliInputs = cliInputsState.getCliInputPayload(); let existingAttributeDefinitions: DynamoDBCLIInputsKeyType[] = []; From 2f8435811dd6f619289008907847def51760c6c7 Mon Sep 17 00:00:00 2001 From: Ghosh Date: Thu, 30 Sep 2021 22:30:54 -0700 Subject: [PATCH 3/7] fix: remove older files post migration --- .../dynamoDB-input-state.ts | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts index 8bc0d845719..e83461cf33b 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts @@ -37,6 +37,30 @@ export class DynamoDBInputState { return cliInputs; } + public cliInputFileExists(): boolean { + return fs.existsSync(this._cliInputsFilePath); + } + + public isCLIInputsValid(cliInputs?: DynamoDBCLIInputs) { + if (!cliInputs) { + cliInputs = this.getCliInputPayload(); + } + + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'DynamoDBCLIInputs'); + schemaValidator.validateInput(JSON.stringify(cliInputs)); + } + + public saveCliInputPayload(cliInputs: DynamoDBCLIInputs): void { + this.isCLIInputsValid(cliInputs); + + fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); + try { + JSONUtilities.writeJson(this._cliInputsFilePath, cliInputs); + } catch (e) { + throw new Error(e); + } + } + public migrate() { let cliInputs: DynamoDBCLIInputs; const attrReverseMap: any = { @@ -127,32 +151,17 @@ export class DynamoDBInputState { }; this.saveCliInputPayload(cliInputs); - } - public cliInputFileExists(): boolean { - if (fs.existsSync(this._cliInputsFilePath)) { - return true; - } - return false; - } + // Remove old files - public isCLIInputsValid(cliInputs?: DynamoDBCLIInputs) { - if (!cliInputs) { - cliInputs = this.getCliInputPayload(); + if (fs.existsSync(oldCFNFilepath)) { + fs.removeSync(oldCFNFilepath); } - - const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'DynamoDBCLIInputs'); - schemaValidator.validateInput(JSON.stringify(cliInputs)); - } - - public saveCliInputPayload(cliInputs: DynamoDBCLIInputs): void { - this.isCLIInputsValid(cliInputs); - - fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); - try { - JSONUtilities.writeJson(this._cliInputsFilePath, cliInputs); - } catch (e) { - throw new Error(e); + if (fs.existsSync(oldParametersFilepath)) { + fs.removeSync(oldParametersFilepath); + } + if (fs.existsSync(oldStorageParamsFilepath)) { + fs.removeSync(oldStorageParamsFilepath); } } } From ea24da73b0dd59cd59ef439332fea4c4f81f73bf Mon Sep 17 00:00:00 2001 From: Ghosh Date: Thu, 30 Sep 2021 23:17:51 -0700 Subject: [PATCH 4/7] fix: address PR comments --- .../overrides-resource/DynamoDB/override.ts | 3 +- .../src/commands/storage/override.ts | 27 +++++---- .../amplify-category-storage/src/index.ts | 19 +++--- .../cdk-stack-builder/ddb-stack-transform.ts | 58 +++++++++---------- .../dynamoDB-input-state.ts | 6 +- .../dynamoDb-walkthrough.ts | 26 ++++----- .../amplify-category-storage/tsconfig.json | 4 +- 7 files changed, 71 insertions(+), 72 deletions(-) diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts index c02cee5ebd4..95ad315252c 100644 --- a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts @@ -1,6 +1,7 @@ /* Add Amplify Helper dependencies */ /* TODO: Need to change props to Root-Stack specific props when props are ready */ -export function overrideProps(props: any): void { +export function overrideProps(props: any) { /* TODO: Add snippet of how to override in comments */ + return props; } diff --git a/packages/amplify-category-storage/src/commands/storage/override.ts b/packages/amplify-category-storage/src/commands/storage/override.ts index ebfe4769950..4b56910d6a1 100644 --- a/packages/amplify-category-storage/src/commands/storage/override.ts +++ b/packages/amplify-category-storage/src/commands/storage/override.ts @@ -2,25 +2,25 @@ entry code for amplify override root */ -import path from 'path'; -import { generateOverrideSkeleton, $TSContext, FeatureFlags } from 'amplify-cli-core'; +import { generateOverrideSkeleton, $TSContext, FeatureFlags, stateManager, pathManager } from 'amplify-cli-core'; import { printer } from 'amplify-prompts'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import { DynamoDBInputState } from '../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; import { DDBStackTransform } from '../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import * as path from 'path'; +import { categoryName } from '../../constants'; -const category = 'storage'; export const name = 'override'; export const run = async (context: $TSContext) => { if (FeatureFlags.getBoolean('overrides.project')) { const { amplify } = context; - const { amplifyMeta } = amplify.getProjectDetails(); + const amplifyMeta = stateManager.getMeta(); const storageResources: string[] = []; - Object.keys(amplifyMeta[category]).forEach(resourceName => { + Object.keys(amplifyMeta[categoryName]).forEach(resourceName => { storageResources.push(resourceName); }); @@ -30,7 +30,7 @@ export const run = async (context: $TSContext) => { return; } - let selectedResource: string = storageResources[0]; + let selectedResourceName: string = storageResources[0]; if (storageResources.length > 1) { const resourceAnswer = await inquirer.prompt({ @@ -39,11 +39,10 @@ export const run = async (context: $TSContext) => { message: 'Which resource would you like to add overrides for?', choices: storageResources, }); - selectedResource = resourceAnswer.resource; + selectedResourceName = resourceAnswer.resource; } - const backendDir = context.amplify.pathManager.getBackendDirPath(); - const destPath = path.join(backendDir, category, selectedResource); + const destPath = pathManager.getResourceDirectoryPath(undefined, categoryName, selectedResourceName); fs.ensureDirSync(destPath); const srcPath = path.join( @@ -53,22 +52,22 @@ export const run = async (context: $TSContext) => { '..', 'resources', 'overrides-resource', - amplifyMeta[category][selectedResource].service, + amplifyMeta[categoryName][selectedResourceName].service, ); // Make sure to migrate first - if (amplifyMeta[category][selectedResource].service === 'DynamoDB') { - const resourceInputState = new DynamoDBInputState(selectedResource); + if (amplifyMeta[categoryName][selectedResourceName].service === 'DynamoDB') { + const resourceInputState = new DynamoDBInputState(selectedResourceName); if (!resourceInputState.cliInputFileExists()) { if (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) { resourceInputState.migrate(); - const stackGenerator = new DDBStackTransform(selectedResource); + const stackGenerator = new DDBStackTransform(selectedResourceName); stackGenerator.transform(); } else { return; } } - } else if (amplifyMeta[category][selectedResource].service === 'S3') { + } else if (amplifyMeta[categoryName][selectedResourceName].service === 'S3') { // S3 migration logic goes in here } diff --git a/packages/amplify-category-storage/src/index.ts b/packages/amplify-category-storage/src/index.ts index 274a986f67c..4eca6161280 100644 --- a/packages/amplify-category-storage/src/index.ts +++ b/packages/amplify-category-storage/src/index.ts @@ -1,5 +1,6 @@ import * as path from 'path'; -const sequential = require('promise-sequential'); +import sequential from 'promise-sequential'; +import { printer } from 'amplify-prompts'; import { updateConfigOnEnvInit } from './provider-utils/awscloudformation'; import { $TSContext, AmplifyCategories, IAmplifyResource, pathManager } from 'amplify-cli-core'; import { DDBStackTransform } from './provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; @@ -15,7 +16,7 @@ async function add(context: any, providerName: any, service: any) { const providerController = require(`./provider-utils/${providerName}`); if (!providerController) { - context.print.error('Provider not configured for this category'); + printer.error('Provider not configured for this category'); return; } @@ -23,7 +24,7 @@ async function add(context: any, providerName: any, service: any) { } async function categoryConsole(context: any) { - context.print.info(`to be implemented: ${AmplifyCategories.STORAGE} console`); + printer.info(`to be implemented: ${AmplifyCategories.STORAGE} console`); } async function migrateStorageCategory(context: any) { @@ -46,10 +47,10 @@ async function migrateStorageCategory(context: any) { ), ); } else { - context.print.error(`Provider not configured for ${AmplifyCategories.STORAGE}: ${resourceName}`); + printer.error(`Provider not configured for ${AmplifyCategories.STORAGE}: ${resourceName}`); } } catch (e) { - context.print.warning(`Could not run migration for ${AmplifyCategories.STORAGE}: ${resourceName}`); + printer.warn(`Could not run migration for ${AmplifyCategories.STORAGE}: ${resourceName}`); throw e; } }); @@ -108,10 +109,10 @@ async function getPermissionPolicies(context: any, resourceOpsMapping: any) { } resourceAttributes.push({ resourceName, attributes, storageCategory }); } else { - context.print.error(`Provider not configured for ${storageCategory}: ${resourceName}`); + printer.error(`Provider not configured for ${storageCategory}: ${resourceName}`); } } catch (e) { - context.print.warning(`Could not get policies for ${storageCategory}: ${resourceName}`); + printer.warn(`Could not get policies for ${storageCategory}: ${resourceName}`); throw e; } }); @@ -134,8 +135,8 @@ async function executeAmplifyCommand(context: any) { } async function handleAmplifyEvent(context: any, args: any) { - context.print.info(`${AmplifyCategories.STORAGE} handleAmplifyEvent to be implemented`); - context.print.info(`Received event args ${args}`); + printer.info(`${AmplifyCategories.STORAGE} handleAmplifyEvent to be implemented`); + printer.info(`Received event args ${args}`); } async function initEnv(context: any) { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts index 20d361f3b33..4342016172b 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts @@ -5,17 +5,17 @@ import { AmplifyDDBResourceInputParameters, AmplifyDDBResourceTemplate } from '. import { App } from '@aws-cdk/core'; import * as cdk from '@aws-cdk/core'; import * as fs from 'fs-extra'; -import { JSONUtilities, pathManager, buildOverrideDir } from 'amplify-cli-core'; -import path from 'path'; +import { JSONUtilities, pathManager, buildOverrideDir, $TSAny } from 'amplify-cli-core'; +import * as path from 'path'; import { formatter, printer } from 'amplify-prompts'; export class DDBStackTransform { app: App; - cliInputs: DynamoDBCLIInputs; + _cliInputs: DynamoDBCLIInputs; _resourceTemplateObj: AmplifyDDBResourceStack | undefined; - cliInputsState: DynamoDBInputState; - cfn!: string; - cfnInputParams!: AmplifyDDBResourceInputParameters; + _cliInputsState: DynamoDBInputState; + _cfn!: string; + _cfnInputParams!: AmplifyDDBResourceInputParameters; _resourceName: string; constructor(resourceName: string) { @@ -23,16 +23,16 @@ export class DDBStackTransform { this._resourceName = resourceName; // Validate the cli-inputs.json for the resource - this.cliInputsState = new DynamoDBInputState(resourceName); - this.cliInputs = this.cliInputsState.getCliInputPayload(); - this.cliInputsState.isCLIInputsValid(); + this._cliInputsState = new DynamoDBInputState(resourceName); + this._cliInputs = this._cliInputsState.getCliInputPayload(); + this._cliInputsState.isCLIInputsValid(); } async transform() { // Generate cloudformation stack from cli-inputs.json await this.generateStack(); - // Generate cloudformation stack from cli-inputs.json + // Generate cloudformation stack input params from cli-inputs.json this.generateCfnInputParameters(); // Modify cloudformation files based on overrides @@ -43,19 +43,19 @@ export class DDBStackTransform { } generateCfnInputParameters() { - this.cfnInputParams = { - tableName: this.cliInputs.tableName, - partitionKeyName: this.cliInputs.partitionKey.fieldName, - partitionKeyType: this.cliInputs.partitionKey.fieldType, + this._cfnInputParams = { + tableName: this._cliInputs.tableName, + partitionKeyName: this._cliInputs.partitionKey.fieldName, + partitionKeyType: this._cliInputs.partitionKey.fieldType, }; - if (this.cliInputs.sortKey) { - this.cfnInputParams.sortKeyName = this.cliInputs.sortKey.fieldName; - this.cfnInputParams.sortKeyType = this.cliInputs.sortKey.fieldType; + if (this._cliInputs.sortKey) { + this._cfnInputParams.sortKeyName = this._cliInputs.sortKey.fieldName; + this._cfnInputParams.sortKeyType = this._cliInputs.sortKey.fieldType; } } async generateStack() { - this._resourceTemplateObj = new AmplifyDDBResourceStack(this.app, 'AmplifyDDBResourceStack', this.cliInputs); + this._resourceTemplateObj = new AmplifyDDBResourceStack(this.app, 'AmplifyDDBResourceStack', this._cliInputs); // Add Parameters this._resourceTemplateObj.addCfnParameter( @@ -76,7 +76,7 @@ export class DDBStackTransform { }, 'env', ); - if (this.cliInputs.sortKey) { + if (this._cliInputs.sortKey) { this._resourceTemplateObj.addCfnParameter( { type: 'String', @@ -143,7 +143,7 @@ export class DDBStackTransform { 'PartitionKeyType', ); - if (this.cliInputs.sortKey) { + if (this._cliInputs.sortKey) { this._resourceTemplateObj.addCfnOutput( { value: cdk.Fn.ref('sortKeyName'), @@ -168,7 +168,7 @@ export class DDBStackTransform { async applyOverrides() { const backendDir = pathManager.getBackendDirPath(); - const overrideFilePath = path.join(backendDir, 'storage', this._resourceName); + const overrideFilePath = pathManager.getResourceDirectoryPath(undefined, 'storage', this._resourceName); const isBuild = await buildOverrideDir(backendDir, overrideFilePath).catch(error => { printer.warn(`Skipping build as ${error.message}`); @@ -192,7 +192,7 @@ export class DDBStackTransform { // const script = new vm.Script(overrideCode); // script.runInContext(vm.createContext(cognitoStackTemplateObj)); return; - } catch (error: any) { + } catch (error: $TSAny) { throw new Error(error); } } @@ -201,23 +201,23 @@ export class DDBStackTransform { saveBuildFiles() { if (this._resourceTemplateObj) { - this.cfn = JSON.parse(this._resourceTemplateObj.renderCloudFormationTemplate()); + this._cfn = JSON.parse(this._resourceTemplateObj.renderCloudFormationTemplate()); } // store files in local-filesysten - fs.ensureDirSync(this.cliInputsState.buildFilePath); - const cfnFilePath = path.resolve(path.join(this.cliInputsState.buildFilePath, 'cloudformation-template.json')); + fs.ensureDirSync(this._cliInputsState.buildFilePath); + const cfnFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, 'cloudformation-template.json')); try { - JSONUtilities.writeJson(cfnFilePath, this.cfn); + JSONUtilities.writeJson(cfnFilePath, this._cfn); } catch (e) { throw new Error(e); } - fs.ensureDirSync(this.cliInputsState.buildFilePath); - const cfnInputParamsFilePath = path.resolve(path.join(this.cliInputsState.buildFilePath, 'parameters.json')); + fs.ensureDirSync(this._cliInputsState.buildFilePath); + const cfnInputParamsFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, 'parameters.json')); try { - JSONUtilities.writeJson(cfnInputParamsFilePath, this.cfnInputParams); + JSONUtilities.writeJson(cfnInputParamsFilePath, this._cfnInputParams); } catch (e) { throw new Error(e); } diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts index e83461cf33b..f8b111415fc 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts @@ -1,7 +1,5 @@ import { DynamoDBCLIInputs, DynamoDBCLIInputsGSIType } from '../service-walkthrough-types/dynamoDB-user-input-types'; -import { AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; -import { JSONUtilities, pathManager } from 'amplify-cli-core'; -import { CLIInputSchemaValidator } from 'amplify-cli-core'; +import { AmplifyCategories, AmplifySupportedService, CLIInputSchemaValidator, JSONUtilities, pathManager } from 'amplify-cli-core'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -29,7 +27,7 @@ export class DynamoDBInputState { // Read cliInputs file if exists try { - cliInputs = JSON.parse(fs.readFileSync(this._cliInputsFilePath, 'utf8')); + cliInputs = JSONUtilities.readJson(this._cliInputsFilePath) as DynamoDBCLIInputs; } catch (e) { throw new Error('cli-inputs.json file missing from the resource directory'); } diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts index e1cfd8cfccd..bb5df4bc3f8 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -17,10 +17,10 @@ const FunctionServiceNameLambdaFunction = 'Lambda'; const serviceName = 'DynamoDB'; async function addWalkthrough(context: $TSContext, defaultValuesFilename: string) { - printer.info(''); + printer.blankLine(); printer.info('Welcome to the NoSQL DynamoDB database wizard'); printer.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); - printer.info(''); + printer.blankLine(); const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); const { getAllDefaults } = require(defaultValuesSrc); @@ -228,7 +228,7 @@ async function askGSIQuestion( attributeDefinitions: DynamoDBAttributeDefType[], existingGSIList?: DynamoDBCLIInputsGSIType[], ) { - printer.info(''); + printer.blankLine(); printer.info( 'You can optionally add global secondary indexes for this table. These are useful when you run queries defined in a different column than the primary key.', ); @@ -236,7 +236,7 @@ async function askGSIQuestion( printer.info( 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.SecondaryIndexes', ); - printer.info(''); + printer.blankLine(); const { amplify } = context; let gsiList: DynamoDBCLIInputsGSIType[] = []; @@ -361,16 +361,16 @@ async function askSortKeyQuestion( } async function askPrimaryKeyQuestion(indexableAttributeList: string[], attributeDefinitions: DynamoDBAttributeDefType[]) { - printer.info(''); + printer.blankLine(); printer.info( 'Before you create the database, you must specify how items in your table are uniquely organized. You do this by specifying a primary key. The primary key uniquely identifies each item in the table so that no two items can have the same key. This can be an individual column, or a combination that includes a primary key and a sort key.', ); - printer.info(''); + printer.blankLine(); printer.info('To learn more about primary keys, see:'); printer.info( 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey', ); - printer.info(''); + printer.blankLine(); const primaryKeyQuestion = { type: 'list', @@ -404,9 +404,9 @@ async function askAttributeListQuestion(context: $TSContext, existingAttributeDe 'binary-set': { code: 'binary-set', indexable: false }, }; - printer.info(''); + printer.blankLine(); printer.info('You can now add columns to the table.'); - printer.info(''); + printer.blankLine(); const QUESTION_KEY = 'attribute'; const { amplify } = context; @@ -530,7 +530,7 @@ async function askResourceNameQuestion(context: $TSContext, defaultValues: any): return answer.resourceName; } -async function removeTrigger(context: any, resourceName: string, triggerList: string[]) { +async function removeTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { const triggerOptionQuestion = { type: 'list', name: 'triggerOption', @@ -559,7 +559,7 @@ async function removeTrigger(context: any, resourceName: string, triggerList: st return functionName; } -async function addTrigger(context: any, resourceName: string, triggerList: string[]) { +async function addTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { const triggerTypeQuestion = { type: 'list', name: 'triggerType', @@ -761,7 +761,7 @@ async function addTrigger(context: any, resourceName: string, triggerList: strin return functionName; } -async function getLambdaFunctions(context: any) { +async function getLambdaFunctions(context: $TSContext) { const { allResources } = await context.amplify.getResourceStatus(); const lambdaResources = allResources .filter((resource: any) => resource.service === FunctionServiceNameLambdaFunction) @@ -770,7 +770,7 @@ async function getLambdaFunctions(context: any) { return lambdaResources; } -function migrateCategory(context: any, projectPath: any, resourceName: any) { +function migrateCategory(context: $TSContext, projectPath: any, resourceName: any) { const resourceDirPath = path.join(projectPath, 'amplify', 'backend', AmplifyCategories.STORAGE, resourceName); const cfnFilePath = path.join(resourceDirPath, `${resourceName}-cloudformation-template.json`); diff --git a/packages/amplify-category-storage/tsconfig.json b/packages/amplify-category-storage/tsconfig.json index 4fc10f8059f..6d6385a3654 100644 --- a/packages/amplify-category-storage/tsconfig.json +++ b/packages/amplify-category-storage/tsconfig.json @@ -11,8 +11,8 @@ "resources", ], "references": [ - {"path": "../amplify-cli-core"}, + { "path": "../amplify-cli-core" }, { "path": "../amplify-prompts" }, - {"path": "../amplify-util-import"}, + { "path": "../amplify-util-import" } ] } From be733a88daa4ddf5ba752343fb235e242d855b79 Mon Sep 17 00:00:00 2001 From: Ghosh Date: Fri, 1 Oct 2021 18:06:45 -0700 Subject: [PATCH 5/7] fix: remove inquirer anf use amplif prompts instead --- .../dynamoDb-walkthrough.ts | 304 ++++++------------ 1 file changed, 93 insertions(+), 211 deletions(-) diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts index bb5df4bc3f8..2934af82a24 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -1,8 +1,8 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import uuid from 'uuid'; -import { printer } from 'amplify-prompts'; -import { $TSContext, AmplifyCategories, pathManager, ResourceDoesNotExistError, exitOnNextTick } from 'amplify-cli-core'; +import { alphanumeric, printer, prompter, Validator } from 'amplify-prompts'; +import { $TSContext, AmplifyCategories, ResourceDoesNotExistError, exitOnNextTick } from 'amplify-cli-core'; import { DynamoDBInputState } from './dynamoDB-input-state'; import { DynamoDBAttributeDefType, @@ -11,7 +11,7 @@ import { DynamoDBCLIInputsKeyType, } from '../service-walkthrough-types/dynamoDB-user-input-types'; import { DDBStackTransform } from '../cdk-stack-builder/ddb-stack-transform'; -const inquirer = require('inquirer'); + // keep in sync with ServiceName in amplify-AmplifyCategories.STORAGE-function, but probably it will not change const FunctionServiceNameLambdaFunction = 'Lambda'; const serviceName = 'DynamoDB'; @@ -27,10 +27,10 @@ async function addWalkthrough(context: $TSContext, defaultValuesFilename: string const { amplify } = context; const defaultValues = getAllDefaults(amplify.getProjectDetails()); - const resourceName = await askResourceNameQuestion(context, defaultValues); // Cannot be changed once added - const tableName = await askTableNameQuestion(context, defaultValues, resourceName); // Cannot be changed once added + const resourceName = await askResourceNameQuestion(defaultValues); // Cannot be changed once added + const tableName = await askTableNameQuestion(defaultValues, resourceName); // Cannot be changed once added - const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(context); + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(); const partitionKey = await askPrimaryKeyQuestion(indexableAttributeList, attributeAnswers); // Cannot be changed once added @@ -40,9 +40,9 @@ async function addWalkthrough(context: $TSContext, defaultValuesFilename: string partitionKey, }; - cliInputs.sortKey = await askSortKeyQuestion(context, indexableAttributeList, attributeAnswers, cliInputs.partitionKey.fieldName); + cliInputs.sortKey = await askSortKeyQuestion(indexableAttributeList, attributeAnswers, cliInputs.partitionKey.fieldName); - cliInputs.gsi = await askGSIQuestion(context, indexableAttributeList, attributeAnswers); + cliInputs.gsi = await askGSIQuestion(indexableAttributeList, attributeAnswers); cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName); @@ -82,27 +82,15 @@ async function updateWalkthrough(context: $TSContext) { } const resources = Object.keys(dynamoDbResources); - const question = [ - { - name: 'resourceName', - message: 'Specify the resource that you would want to update', - type: 'list', - choices: resources, - }, - ]; - - const answer = await inquirer.prompt(question); + const resourceName = await prompter.pick('Specify the resource that you would want to update', resources); // Check if we need to migrate to cli-inputs.json - const cliInputsState = new DynamoDBInputState(answer.resourceName); + const cliInputsState = new DynamoDBInputState(resourceName); if (!cliInputsState.cliInputFileExists()) { - if ( - context.exeInfo?.forcePush || - (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) - ) { + if (context.exeInfo?.forcePush || (await prompter.yesOrNo('File migration required to continue. Do you want to continue?', true))) { cliInputsState.migrate(); - const stackGenerator = new DDBStackTransform(answer.resourceName); + const stackGenerator = new DDBStackTransform(resourceName); stackGenerator.transform(); } else { return; @@ -134,9 +122,9 @@ async function updateWalkthrough(context: $TSContext) { throw new Error('resourceName not found in cli-inputs'); } - const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(context, existingAttributeDefinitions); + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(existingAttributeDefinitions); - cliInputs.gsi = await askGSIQuestion(context, indexableAttributeList, attributeAnswers, cliInputs.gsi); + cliInputs.gsi = await askGSIQuestion(indexableAttributeList, attributeAnswers, cliInputs.gsi); cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName, cliInputs.triggerFunctions); cliInputsState.saveCliInputPayload(cliInputs); @@ -148,11 +136,10 @@ async function updateWalkthrough(context: $TSContext) { } async function askTriggersQuestion(context: $TSContext, resourceName: string, existingTriggerFunctions?: string[]): Promise { - const { amplify } = context; let triggerFunctions: string[] = existingTriggerFunctions || []; if (!existingTriggerFunctions || existingTriggerFunctions.length === 0) { - if (await amplify.confirmPrompt('Do you want to add a Lambda Trigger for your Table?', false)) { + if (await prompter.confirmContinue('Do you want to add a Lambda Trigger for your Table?')) { let triggerName; try { // @ts-expect-error ts-migrate(2554) FIXME: Expected 3 arguments, but got 2. @@ -163,19 +150,17 @@ async function askTriggersQuestion(context: $TSContext, resourceName: string, ex } } } else { - const triggerOperationQuestion = { - type: 'list', - name: 'triggerOperation', - message: 'Select from the following options', - choices: ['Add a Trigger', 'Remove a trigger', 'Skip Question'], - }; let triggerName; let continueWithTriggerOperationQuestion = true; while (continueWithTriggerOperationQuestion) { - const triggerOperationAnswer = await inquirer.prompt([triggerOperationQuestion]); + const triggerOperationAnswer = await prompter.pick('Select from the following options', [ + 'Add a Trigger', + 'Remove a trigger', + `I'm done`, + ]); - switch (triggerOperationAnswer.triggerOperation) { + switch (triggerOperationAnswer) { case 'Add a Trigger': { try { triggerName = await addTrigger(context, resourceName, triggerFunctions); @@ -210,12 +195,12 @@ async function askTriggersQuestion(context: $TSContext, resourceName: string, ex break; } - case 'Skip Question': { + case `I'm done`: { continueWithTriggerOperationQuestion = false; break; } default: - printer.error(`${triggerOperationAnswer.triggerOperation} not supported`); + printer.error(`${triggerOperationAnswer} not supported`); } } } @@ -223,7 +208,6 @@ async function askTriggersQuestion(context: $TSContext, resourceName: string, ex } async function askGSIQuestion( - context: $TSContext, indexableAttributeList: string[], attributeDefinitions: DynamoDBAttributeDefType[], existingGSIList?: DynamoDBCLIInputsGSIType[], @@ -238,85 +222,63 @@ async function askGSIQuestion( ); printer.blankLine(); - const { amplify } = context; let gsiList: DynamoDBCLIInputsGSIType[] = []; if ( existingGSIList && !!existingGSIList.length && - (await amplify.confirmPrompt('Do you want to keep existing global seconday indexes created on your table?')) + (await prompter.yesOrNo('Do you want to keep existing global seconday indexes created on your table?', true)) ) { gsiList = existingGSIList; } - if (await amplify.confirmPrompt('Do you want to add global secondary indexes to your table?')) { + if (await prompter.confirmContinue('Do you want to add global secondary indexes to your table?')) { let continuewithGSIQuestions = true; while (continuewithGSIQuestions) { if (indexableAttributeList.length > 0) { - const gsiAttributeQuestion = { - type: 'input', - name: 'gsiName', - message: 'Please provide the GSI name:', - validate: amplify.inputValidation({ - validation: { - operator: 'regex', - value: '^[a-zA-Z0-9_-]+$', - onErrorMsg: 'You can use the following characters: a-z A-Z 0-9 - _', - }, - }), - }; - const gsiPrimaryKeyQuestion = { - type: 'list', - name: 'gsiPartitionKey', - message: 'Please choose partition key for the GSI:', - choices: [...new Set(indexableAttributeList)], - }; + const gsiNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9_-]+$/.test(input) ? true : message; + + const gsiName = await prompter.input('Provide the GSI name', { + validate: gsiNameValidator('You can use the following characters: a-z A-Z 0-9 - _'), + }); - /*eslint-disable*/ - const gsiPrimaryAnswer = await inquirer.prompt([gsiAttributeQuestion, gsiPrimaryKeyQuestion]); + const gsiPartitionKeyName = await prompter.pick('Choose partition key for the GSI', [...new Set(indexableAttributeList)]); - const gsiPrimaryKeyName = gsiPrimaryAnswer['gsiPartitionKey']; const gsiPrimaryKeyIndex = attributeDefinitions.findIndex( - (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiPrimaryKeyName, + (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiPartitionKeyName, ); /* eslint-enable */ let gsiItem: DynamoDBCLIInputsGSIType = { - name: gsiPrimaryAnswer['gsiName'], + name: gsiName, partitionKey: { - fieldName: gsiPrimaryKeyName, + fieldName: gsiPartitionKeyName, fieldType: attributeDefinitions[gsiPrimaryKeyIndex].AttributeType, }, }; - const sortKeyOptions = indexableAttributeList.filter((att: string) => att !== gsiPrimaryKeyName); + const sortKeyOptions = indexableAttributeList.filter((att: string) => att !== gsiPartitionKeyName); if (sortKeyOptions.length > 0) { - if (await amplify.confirmPrompt('Do you want to add a sort key to your global secondary index?')) { - const sortKeyQuestion = { - type: 'list', - name: 'gsiSortKey', - message: 'Please choose sort key for the GSI:', - choices: [...new Set(sortKeyOptions)], - }; - - const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); - - const gsiSortKeyName = sortKeyAnswer['gsiSortKey']; + if (await prompter.confirmContinue('Do you want to add a sort key to your global secondary index?')) { + const gsiSortKeyName = await prompter.pick('Choose sort key for the GSI', [...new Set(sortKeyOptions)]); const gsiSortKeyIndex = attributeDefinitions.findIndex( (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiSortKeyName, ); gsiItem.sortKey = { - fieldName: sortKeyAnswer['gsiSortKey'], + fieldName: gsiSortKeyName, fieldType: attributeDefinitions[gsiSortKeyIndex].AttributeType, }; } } gsiList.push(gsiItem); - continuewithGSIQuestions = await amplify.confirmPrompt('Do you want to add more global secondary indexes to your table?'); + continuewithGSIQuestions = await prompter.confirmContinue('Do you want to add more global secondary indexes to your table?'); } else { printer.error('You do not have any other attributes remaining to configure'); break; @@ -327,26 +289,17 @@ async function askGSIQuestion( } async function askSortKeyQuestion( - context: $TSContext, indexableAttributeList: string[], attributeDefinitions: DynamoDBAttributeDefType[], partitionKeyFieldName: string, ): Promise { - const { amplify } = context; - - if (await amplify.confirmPrompt('Do you want to add a sort key to your table?')) { + if (await prompter.confirmContinue('Do you want to add a sort key to your table?')) { // Ask for sort key if (attributeDefinitions.length > 1) { - const sortKeyQuestion = { - type: 'list', - name: 'sortKey', - message: 'Please choose sort key for the table:', - choices: indexableAttributeList.filter((att: string) => att !== partitionKeyFieldName), - }; - - const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); - - const sortKeyName = sortKeyAnswer['sortKey']; + const sortKeyName = await prompter.pick( + 'Choose sort key for the table', + indexableAttributeList.filter((att: string) => att !== partitionKeyFieldName), + ); const sortKeyAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === sortKeyName); return { @@ -372,16 +325,7 @@ async function askPrimaryKeyQuestion(indexableAttributeList: string[], attribute ); printer.blankLine(); - const primaryKeyQuestion = { - type: 'list', - name: 'partitionKey', - message: 'Please choose partition key for the table:', - choices: indexableAttributeList, - }; - - const partitionKeyAnswer = await inquirer.prompt([primaryKeyQuestion]); - - const partitionKeyName = partitionKeyAnswer['partitionKey']; + const partitionKeyName = await prompter.pick('Choose partition key for the table', indexableAttributeList); const primaryAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === partitionKeyName); return { @@ -390,7 +334,7 @@ async function askPrimaryKeyQuestion(indexableAttributeList: string[], attribute }; } -async function askAttributeListQuestion(context: $TSContext, existingAttributeDefinitions?: DynamoDBCLIInputsKeyType[]) { +async function askAttributeListQuestion(existingAttributeDefinitions?: DynamoDBCLIInputsKeyType[]) { const attributeTypes = { string: { code: 'string', indexable: true }, number: { code: 'number', indexable: true }, @@ -408,29 +352,6 @@ async function askAttributeListQuestion(context: $TSContext, existingAttributeDe printer.info('You can now add columns to the table.'); printer.blankLine(); - const QUESTION_KEY = 'attribute'; - const { amplify } = context; - - const attributeQuestion = { - type: 'input', - name: QUESTION_KEY, - message: 'What would you like to name this column:', - validate: amplify.inputValidation({ - validation: { - operator: 'regex', - value: '^[a-zA-Z0-9_-]+$', - onErrorMsg: "'You can use the following characters: a-z A-Z 0-9 - _'", - }, - }), - }; - - const attributeTypeQuestion = { - type: 'list', - name: 'attributeType', - message: 'Please choose the data type:', - choices: Object.keys(attributeTypes), - }; - let continueAttributeQuestion = true; let attributeAnswers: DynamoDBAttributeDefType[] = []; let indexableAttributeList: string[] = []; @@ -448,99 +369,72 @@ async function askAttributeListQuestion(context: $TSContext, existingAttributeDe if (existingAttributes.length > 0) { attributeAnswers = existingAttributes; indexableAttributeList = attributeAnswers.map((attr: DynamoDBAttributeDefType) => attr.AttributeName); - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); + continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); } while (continueAttributeQuestion) { - const attributeAnswer = await inquirer.prompt([attributeQuestion, attributeTypeQuestion]); + const attributeNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9_-]+$/.test(input) ? true : message; - if ( - attributeAnswers.findIndex((attribute: DynamoDBAttributeDefType) => attribute.AttributeName === attributeAnswer[QUESTION_KEY]) !== -1 - ) { - continueAttributeQuestion = await amplify.confirmPrompt('This attribute was already added. Do you want to add another attribute?'); + const attributeName = await prompter.input('What would you like to name this column', { + validate: attributeNameValidator('You can use the following characters: a-z A-Z 0-9 - _'), + }); + + const attributeType = await prompter.pick('Choose the data type', Object.keys(attributeTypes)); + + if (attributeAnswers.findIndex((attribute: DynamoDBAttributeDefType) => attribute.AttributeName === attributeName) !== -1) { + continueAttributeQuestion = await prompter.confirmContinue('This attribute was already added. Do you want to add another attribute?'); continue; } attributeAnswers.push({ - AttributeName: attributeAnswer[QUESTION_KEY], + AttributeName: attributeName, // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - AttributeType: attributeTypes[attributeAnswer['attributeType']].code, + AttributeType: attributeTypes[attributeType].code, }); // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - if (attributeTypes[attributeAnswer['attributeType']].indexable) { - indexableAttributeList.push(attributeAnswer[QUESTION_KEY]); + if (attributeTypes[attributeType].indexable) { + indexableAttributeList.push(attributeName); } - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); + continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); } return { attributeAnswers, indexableAttributeList }; } -async function askTableNameQuestion(context: $TSContext, defaultValues: any, resourceName: string) { - const { amplify } = context; - - const question = [ - { - type: 'input', - name: 'tableName', - message: 'Please provide table name:', - validate: amplify.inputValidation({ - validation: { - operator: 'regex', - value: '^[a-zA-Z0-9._-]+$', - onErrorMsg: 'You can use the following characters: a-z A-Z 0-9 . - _', - }, - }), - default: (answers: any) => { - const defaultValue = defaultValues['tableName']; - return resourceName || defaultValue; - }, - }, - ]; +async function askTableNameQuestion(defaultValues: any, resourceName: string) { + const tableNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9._-]+$/.test(input) ? true : message; - const answer = await inquirer.prompt(question); + const tableName = await prompter.input('Provide table name:', { + validate: tableNameValidator('You can use the following characters: a-z A-Z 0-9 . - _'), + initial: resourceName || defaultValues['tableName'], + }); - return answer.tableName; + return tableName; } -async function askResourceNameQuestion(context: $TSContext, defaultValues: any): Promise { - const { amplify } = context; - - const question = [ +async function askResourceNameQuestion(defaultValues: any): Promise { + const resourceName = await prompter.input( + 'Provide a friendly name for your resource that will be used to label this category in the project', { - type: 'input', - name: 'resourceName', - message: 'Please provide a friendly name for your resource that will be used to label this category in the project:', - validate: amplify.inputValidation({ - validation: { - operator: 'regex', - value: '^[a-zA-Z0-9]+$', - onErrorMsg: 'Resource name should be alphanumeric', - }, - }), - default: () => { - const defaultValue = defaultValues['resourceName']; - return defaultValue; - }, + validate: alphanumeric(), + initial: defaultValues['resourceName'], }, - ]; - const answer = await inquirer.prompt(question); - return answer.resourceName; + ); + + return resourceName; } async function removeTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { - const triggerOptionQuestion = { - type: 'list', - name: 'triggerOption', - message: 'Select from the function you would like to remove', - choices: triggerList, - }; - - const triggerOptionAnswer = await inquirer.prompt([triggerOptionQuestion]); + const functionName = await prompter.pick('Select from the function you would like to remove', triggerList); - const functionName = triggerOptionAnswer.triggerOption; const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); const functionCFNFilePath = path.join(projectBackendDirPath, 'function', functionName, `${functionName}-cloudformation-template.json`); @@ -560,16 +454,13 @@ async function removeTrigger(context: $TSContext, resourceName: string, triggerL } async function addTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { - const triggerTypeQuestion = { - type: 'list', - name: 'triggerType', - message: 'Select from the following options', - choices: ['Choose an existing function from the project', 'Create a new function'], - }; - const triggerTypeAnswer = await inquirer.prompt([triggerTypeQuestion]); + const triggerTypeAnswer = await prompter.pick('Select from the following options', [ + 'Choose an existing function from the project', + 'Create a new function', + ]); let functionName; - if (triggerTypeAnswer.triggerType === 'Choose an existing function from the project') { + if (triggerTypeAnswer === 'Choose an existing function from the project') { let lambdaResources = await getLambdaFunctions(context); if (triggerList) { @@ -588,16 +479,7 @@ async function addTrigger(context: $TSContext, resourceName: string, triggerList throw new Error("No functions were found in the project. Use 'amplify add function' to add a new function."); } - const triggerOptionQuestion = { - type: 'list', - name: 'triggerOption', - message: 'Select from the following options', - choices: lambdaResources, - }; - - const triggerOptionAnswer = await inquirer.prompt([triggerOptionQuestion]); - - functionName = triggerOptionAnswer.triggerOption; + functionName = await prompter.pick('Select from the following options', lambdaResources); } else { // Create a new lambda trigger @@ -751,7 +633,7 @@ async function addTrigger(context: $TSContext, resourceName: string, triggerList context.amplify.updateamplifyMetaAfterResourceUpdate('function', functionName, 'dependsOn', resourceDependsOn); printer.success(`Successfully updated resource ${functionName} locally`); - if (await context.amplify.confirmPrompt(`Do you want to edit the local ${functionName} lambda function now?`)) { + if (await prompter.confirmContinue(`Do you want to edit the local ${functionName} lambda function now?`)) { await context.amplify.openEditor(context, `${projectBackendDirPath}/function/${functionName}/src/index.js`); } } else { From 3225a1d3545ada8f7d7c06a688163e527d4ab01f Mon Sep 17 00:00:00 2001 From: Ghosh Date: Tue, 5 Oct 2021 11:11:03 -0700 Subject: [PATCH 6/7] chore: overrides ddb and walthrough refactor tests --- .../src/__tests__/commands/add.test.ts | 37 +++ .../src/__tests__/commands/override.test.ts | 92 +++++++ .../src/__tests__/commands/remove.test.ts | 46 ++++ .../src/__tests__/commands/update.test.ts | 34 +++ .../ddb-stack-transform.test.ts.snap | 190 ++++++++++++++ .../ddb-stack-transform.test.ts | 84 ++++++ .../dynamoDb-walkthrough.test.ts | 239 +++++++++++++++++ .../src/commands/storage/override.ts | 95 +++---- .../dynamoDb-walkthrough.ts | 40 ++- .../src/categories/storage.ts | 245 ++++++++++-------- packages/amplify-e2e-tests/Readme.md | 2 +- .../overrides/override-storage-ddb.ts | 6 + packages/amplify-e2e-tests/package.json | 3 +- .../src/__tests__/storage.test.ts | 77 +++++- 14 files changed, 1000 insertions(+), 190 deletions(-) create mode 100644 packages/amplify-category-storage/src/__tests__/commands/add.test.ts create mode 100644 packages/amplify-category-storage/src/__tests__/commands/override.test.ts create mode 100644 packages/amplify-category-storage/src/__tests__/commands/remove.test.ts create mode 100644 packages/amplify-category-storage/src/__tests__/commands/update.test.ts create mode 100644 packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/__snapshots__/ddb-stack-transform.test.ts.snap create mode 100644 packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.test.ts create mode 100644 packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.test.ts create mode 100644 packages/amplify-e2e-tests/overrides/override-storage-ddb.ts diff --git a/packages/amplify-category-storage/src/__tests__/commands/add.test.ts b/packages/amplify-category-storage/src/__tests__/commands/add.test.ts new file mode 100644 index 00000000000..131d6a1d400 --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/commands/add.test.ts @@ -0,0 +1,37 @@ +import { $TSContext, $TSObject } from 'amplify-cli-core'; +import { run } from '../../commands/storage/add'; +import * as providerController from '../../provider-utils/awscloudformation/index'; + +jest.mock('../../provider-utils/awscloudformation/index'); +jest.mock('amplify-cli-core'); + +const providerController_mock = providerController as jest.Mocked; +providerController_mock.addResource.mockImplementation = jest.fn().mockImplementation(async () => { + return 'mockResourceName'; +}); + +describe('add ddb command tests', () => { + const provider = 'awscloudformation'; + let mockContext: $TSContext; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + amplify: {}, + } as unknown as $TSContext; + }); + + it('add resource workflow is invoked for DDB', async () => { + const service = 'DynamoDB'; + mockContext.amplify.serviceSelectionPrompt = jest.fn().mockImplementation(async () => { + return { service: service, providerName: provider }; + }); + + await run(mockContext); + + expect(providerController_mock.addResource).toHaveBeenCalledWith(mockContext, 'storage', service, { + service: service, + providerPlugin: provider, + }); + }); +}); diff --git a/packages/amplify-category-storage/src/__tests__/commands/override.test.ts b/packages/amplify-category-storage/src/__tests__/commands/override.test.ts new file mode 100644 index 00000000000..2d8f9799cfe --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/commands/override.test.ts @@ -0,0 +1,92 @@ +import { $TSContext, $TSObject, stateManager, generateOverrideSkeleton, pathManager } from 'amplify-cli-core'; +import { run } from '../../commands/storage/override'; +import { printer, prompter } from 'amplify-prompts'; +import path from 'path'; +import { DynamoDBInputState } from '../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; + +jest.mock('amplify-cli-core'); +jest.mock('amplify-prompts'); +jest.mock('path'); +jest.mock('../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'); +jest.mock('../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'); + +const generateOverrideSkeleton_mock = generateOverrideSkeleton as jest.MockedFunction; +generateOverrideSkeleton_mock.mockImplementation = jest.fn().mockImplementation(async () => { + return 'mockResourceName'; +}); + +describe('override ddb command tests', () => { + let mockContext: $TSContext; + let mockAmplifyMeta: $TSObject = {}; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + amplify: {}, + } as unknown as $TSContext; + }); + + it('override ddb when two ddb storage resources present', async () => { + mockAmplifyMeta = { + storage: { + dynamo73399689: { + service: 'DynamoDB', + providerPlugin: 'awscloudformation', + }, + dynamoefb50875: { + service: 'DynamoDB', + providerPlugin: 'awscloudformation', + }, + }, + }; + + const destDir = 'mockDir'; + const srcDir = 'mockSrcDir'; + + stateManager.getMeta = jest.fn().mockReturnValue(mockAmplifyMeta); + pathManager.getResourceDirectoryPath = jest.fn().mockReturnValue(destDir); + path.join = jest.fn().mockReturnValue(srcDir); + + prompter.pick = jest.fn().mockReturnValue('dynamo73399689'); + jest.spyOn(DynamoDBInputState.prototype, 'cliInputFileExists').mockImplementation(() => true); + + await run(mockContext); + + expect(prompter.pick).toBeCalledTimes(1); + expect(generateOverrideSkeleton).toHaveBeenCalledWith(mockContext, srcDir, destDir); + }); + + it('override ddb when one ddb storage resource present', async () => { + mockAmplifyMeta = { + storage: { + dynamo73399689: { + service: 'DynamoDB', + providerPlugin: 'awscloudformation', + }, + }, + }; + + const destDir = 'mockDir'; + const srcDir = 'mockSrcDir'; + + stateManager.getMeta = jest.fn().mockReturnValue(mockAmplifyMeta); + pathManager.getResourceDirectoryPath = jest.fn().mockReturnValue(destDir); + path.join = jest.fn().mockReturnValue(srcDir); + + jest.spyOn(DynamoDBInputState.prototype, 'cliInputFileExists').mockImplementation(() => true); + + await run(mockContext); + + // Prompter should not be called when only one ddb/storage resource present + expect(prompter.pick).toBeCalledTimes(0); + expect(generateOverrideSkeleton).toHaveBeenCalledWith(mockContext, srcDir, destDir); + }); + + it('override ddb when no ddb storage resource present', async () => { + mockAmplifyMeta = {}; + stateManager.getMeta = jest.fn().mockReturnValue(mockAmplifyMeta); + + await run(mockContext); + expect(printer.error).toHaveBeenCalledWith('No resources to override. You need to add a resource.'); + }); +}); diff --git a/packages/amplify-category-storage/src/__tests__/commands/remove.test.ts b/packages/amplify-category-storage/src/__tests__/commands/remove.test.ts new file mode 100644 index 00000000000..d51facdb6c6 --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/commands/remove.test.ts @@ -0,0 +1,46 @@ +import { $TSContext, $TSObject } from 'amplify-cli-core'; +import { run } from '../../commands/storage/remove'; +import * as providerController from '../../provider-utils/awscloudformation/index'; + +jest.mock('../../provider-utils/awscloudformation/index'); +jest.mock('amplify-cli-core'); + +const providerController_mock = providerController as jest.Mocked; +providerController_mock.updateResource.mockImplementation = jest.fn().mockImplementation(async () => { + return 'mockResourceName'; +}); + +describe('remove ddb command tests', () => { + const provider = 'awscloudformation'; + let mockContext: $TSContext; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + amplify: {}, + parameters: {}, + } as unknown as $TSContext; + }); + + it('update resource workflow is invoked for DDB with no params', async () => { + mockContext.amplify.removeResource = jest.fn().mockImplementation(async () => { + return; + }); + + await run(mockContext); + + expect(mockContext.amplify.removeResource).toHaveBeenCalledWith(mockContext, 'storage', undefined); + }); + + it('update resource workflow is invoked for DDB with params as resourceName', async () => { + const mockResourceName = 'mockResourceName'; + mockContext.parameters.first = mockResourceName; + mockContext.amplify.removeResource = jest.fn().mockImplementation(async () => { + return; + }); + + await run(mockContext); + + expect(mockContext.amplify.removeResource).toHaveBeenCalledWith(mockContext, 'storage', mockResourceName); + }); +}); diff --git a/packages/amplify-category-storage/src/__tests__/commands/update.test.ts b/packages/amplify-category-storage/src/__tests__/commands/update.test.ts new file mode 100644 index 00000000000..2bc7152c460 --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/commands/update.test.ts @@ -0,0 +1,34 @@ +import { $TSContext, $TSObject } from 'amplify-cli-core'; +import { run } from '../../commands/storage/update'; +import * as providerController from '../../provider-utils/awscloudformation/index'; + +jest.mock('../../provider-utils/awscloudformation/index'); +jest.mock('amplify-cli-core'); + +const providerController_mock = providerController as jest.Mocked; +providerController_mock.updateResource.mockImplementation = jest.fn().mockImplementation(async () => { + return 'mockResourceName'; +}); + +describe('update ddb command tests', () => { + const provider = 'awscloudformation'; + let mockContext: $TSContext; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + amplify: {}, + } as unknown as $TSContext; + }); + + it('update resource workflow is invoked for DDB', async () => { + const service = 'DynamoDB'; + mockContext.amplify.serviceSelectionPrompt = jest.fn().mockImplementation(async () => { + return { service: service, providerName: provider }; + }); + + await run(mockContext); + + expect(providerController_mock.updateResource).toHaveBeenCalledWith(mockContext, 'storage', service); + }); +}); diff --git a/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/__snapshots__/ddb-stack-transform.test.ts.snap b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/__snapshots__/ddb-stack-transform.test.ts.snap new file mode 100644 index 00000000000..c43cfb4473d --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/__snapshots__/ddb-stack-transform.test.ts.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test DDB transform generates correct CFN template Generated ddb template with all CLI configurations set with no overrides 1`] = ` +Object { + "AWSTemplateFormatVersion": "2010-09-09", + "Conditions": Object { + "ShouldNotCreateEnvResources": Object { + "Fn::Equals": Array [ + Object { + "Ref": "env", + }, + "NONE", + ], + }, + }, + "Description": "DDB Resource for AWS Amplify CLI", + "Outputs": Object { + "Arn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "DynamoDBTable", + "Arn", + ], + }, + }, + "Name": Object { + "Value": Object { + "Ref": "DynamoDBTable", + }, + }, + "PartitionKeyName": Object { + "Value": Object { + "Ref": "partitionKeyName", + }, + }, + "PartitionKeyType": Object { + "Value": Object { + "Ref": "partitionKeyType", + }, + }, + "Region": Object { + "Value": Object { + "Ref": "AWS::Region", + }, + }, + "SortKeyName": Object { + "Value": Object { + "Ref": "sortKeyName", + }, + }, + "SortKeyType": Object { + "Value": Object { + "Ref": "sortKeyType", + }, + }, + "StreamArn": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "DynamoDBTable", + "StreamArn", + ], + }, + }, + }, + "Parameters": Object { + "env": Object { + "Type": "String", + }, + "partitionKeyName": Object { + "Type": "String", + }, + "partitionKeyType": Object { + "Type": "String", + }, + "sortKeyName": Object { + "Type": "String", + }, + "sortKeyType": Object { + "Type": "String", + }, + "tableName": Object { + "Type": "String", + }, + }, + "Resources": Object { + "DynamoDBTable": Object { + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "id", + "AttributeType": "S", + }, + Object { + "AttributeName": "name", + "AttributeType": "N", + }, + Object { + "AttributeName": "col", + "AttributeType": "S", + }, + ], + "GlobalSecondaryIndexes": Array [ + Object { + "IndexName": "gsiname", + "KeySchema": Array [ + Object { + "AttributeName": "name", + "KeyType": "HASH", + }, + ], + "Projection": Object { + "ProjectionType": "ALL", + }, + "ProvisionedThroughput": Object { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + }, + Object { + "IndexName": "updategsiname", + "KeySchema": Array [ + Object { + "AttributeName": "col", + "KeyType": "HASH", + }, + ], + "Projection": Object { + "ProjectionType": "ALL", + }, + "ProvisionedThroughput": Object { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + }, + ], + "KeySchema": Array [ + Object { + "AttributeName": "id", + "KeyType": "HASH", + }, + Object { + "AttributeName": "name", + "KeyType": "RANGE", + }, + ], + "ProvisionedThroughput": Object { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + "StreamSpecification": Object { + "StreamViewType": "NEW_IMAGE", + }, + "TableName": Object { + "Fn::If": Array [ + "ShouldNotCreateEnvResources", + Object { + "Ref": "tableName", + }, + Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Ref": "tableName", + }, + "-", + Object { + "Ref": "env", + }, + ], + ], + }, + ], + }, + }, + "Type": "AWS::DynamoDB::Table", + }, + }, +} +`; + +exports[`Test DDB transform generates correct CFN template Generated ddb template with all CLI configurations set with no overrides 2`] = ` +Object { + "partitionKeyName": "id", + "partitionKeyType": "string", + "sortKeyName": "name", + "sortKeyType": "number", + "tableName": "mocktablename", +} +`; diff --git a/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.test.ts b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.test.ts new file mode 100644 index 00000000000..2ed25da6c73 --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.test.ts @@ -0,0 +1,84 @@ +/* These tests, test the tranform and in rurn the cdk builder too which is used within this file */ + +import { JSONUtilities, buildOverrideDir, pathManager } from 'amplify-cli-core'; +import * as fs from 'fs-extra'; +import { DDBStackTransform } from '../../../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import { + DynamoDBCLIInputs, + FieldType, +} from '../../../../provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types'; +import { DynamoDBInputState } from '../../../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; +import path from 'path'; + +jest.mock('amplify-cli-core', () => ({ + buildOverrideDir: jest.fn().mockResolvedValue(false), + JSONUtilities: { + writeJson: jest.fn(), + readJson: jest.fn(), + }, + pathManager: { + getBackendDirPath: jest.fn().mockReturnValue('mockbackendpath'), + getResourceDirectoryPath: jest.fn().mockReturnValue('mockresourcepath'), + }, +})); +jest.mock('fs-extra', () => ({ + readFileSync: () => '{ "Cognito": { "provider": "aws"}}', + existsSync: () => true, + ensureDirSync: jest.fn(), +})); + +jest.mock('path', () => ({ + join: jest.fn().mockReturnValue('mockjoinedpath'), + resolve: jest.fn().mockReturnValue('mockjoinedpath'), +})); + +jest.mock('../../../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'); + +describe('Test DDB transform generates correct CFN template', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Generated ddb template with all CLI configurations set with no overrides', async () => { + const resourceName = 'mockResource'; + const cliInputsJSON: DynamoDBCLIInputs = { + resourceName: resourceName, + tableName: 'mocktablename', + partitionKey: { + fieldName: 'id', + fieldType: FieldType.string, + }, + sortKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + gsi: [ + { + name: 'gsiname', + partitionKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + }, + { + name: 'updategsiname', + partitionKey: { + fieldName: 'col', + fieldType: FieldType.string, + }, + }, + ], + triggerFunctions: [], + }; + + jest.spyOn(DynamoDBInputState.prototype, 'getCliInputPayload').mockImplementation(() => cliInputsJSON); + const ddbTransform = new DDBStackTransform(resourceName); + await ddbTransform.transform(); + + console.log(ddbTransform._cfn); + console.log(ddbTransform._cfnInputParams); + + expect(ddbTransform._cfn).toMatchSnapshot(); + expect(ddbTransform._cfnInputParams).toMatchSnapshot(); + }); +}); diff --git a/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.test.ts b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.test.ts new file mode 100644 index 00000000000..d6af9e7020c --- /dev/null +++ b/packages/amplify-category-storage/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.test.ts @@ -0,0 +1,239 @@ +import { $TSContext, stateManager } from 'amplify-cli-core'; +import { prompter } from 'amplify-prompts'; +import { DynamoDBInputState } from '../../../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; +import { DDBStackTransform } from '../../../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import { addWalkthrough, updateWalkthrough } from '../../../../provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough'; +import { + DynamoDBCLIInputs, + FieldType, +} from '../../../../provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types'; + +jest.mock('amplify-cli-core'); +jest.mock('amplify-prompts'); +jest.mock('../../../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'); +jest.mock('../../../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'); + +describe('add ddb walkthrough tests', () => { + let mockContext: $TSContext; + + beforeEach(() => { + mockContext = { + amplify: { + getProjectDetails: () => { + return { + projectConfig: { + projectName: 'mockProject', + }, + }; + }, + }, + } as unknown as $TSContext; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('addWalkthrough() test', async () => { + jest.spyOn(DynamoDBInputState.prototype, 'saveCliInputPayload').mockImplementation(() => true); + jest.spyOn(DDBStackTransform.prototype, 'transform').mockImplementation(() => Promise.resolve()); + + const expectedCLIInputsJSON: DynamoDBCLIInputs = { + resourceName: 'mockresourcename', + tableName: 'mocktablename', + partitionKey: { + fieldName: 'id', + fieldType: FieldType.string, + }, + sortKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + gsi: [ + { + name: 'gsiname', + partitionKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + }, + { + name: 'secondgsiname', + partitionKey: { + fieldName: 'id', + fieldType: FieldType.string, + }, + sortKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + }, + ], + triggerFunctions: [], + }; + + prompter.input = jest + .fn() + .mockReturnValueOnce('mockresourcename') // Provide a friendly name + .mockResolvedValueOnce('mocktablename') // Provide table name + .mockResolvedValueOnce('id') // What would you like to name this column + .mockResolvedValueOnce('name') // What would you like to name this column + .mockResolvedValueOnce('gsiname') // Provide the GSI name + .mockResolvedValueOnce('secondgsiname'); // Provide the GSI name + + prompter.pick = jest + .fn() + .mockReturnValueOnce('string') // Choose the data type + .mockReturnValueOnce('number') // Choose the data type + .mockReturnValueOnce('id') // Choose partition key for the table + .mockReturnValueOnce('name') // Choose sort key for the table + .mockReturnValueOnce('name') // Choose partition key for the GSI + .mockReturnValueOnce('id') // Choose partition key for the GSI + .mockReturnValueOnce('name'); // Choose sort key for the GSI + + prompter.yesOrNo = jest + .fn() + .mockReturnValueOnce(true) // Would you like to add another column + .mockReturnValueOnce(false) // Would you like to add another column + .mockReturnValueOnce(true) // Do you want to add a sort key to your table? + .mockReturnValueOnce(true) // Do you want to add global secondary indexes to your table? + .mockReturnValueOnce(false) // Do you want to add a sort key to your global secondary index? + .mockReturnValueOnce(true) // Do you want to add more global secondary indexes to your table? + .mockReturnValueOnce(true) // Do you want to add a sort key to your global secondary index? + .mockReturnValueOnce(false); // Do you want to add more global secondary indexes to your table? + + prompter.confirmContinue = jest.fn().mockReturnValueOnce(false); // Do you want to add a Lambda Trigger for your Table? + + const returnedResourcename = await addWalkthrough(mockContext, 'dynamoDb-defaults'); + + expect(returnedResourcename).toEqual('mockresourcename'); + expect(DynamoDBInputState.prototype.saveCliInputPayload).toHaveBeenCalledWith(expectedCLIInputsJSON); + }); +}); + +describe('update ddb walkthrough tests', () => { + let mockContext: $TSContext; + + beforeEach(() => { + jest.mock('amplify-prompts'); + mockContext = { + amplify: { + getProjectDetails: () => { + return { + projectConfig: { + projectName: 'mockProject', + }, + }; + }, + }, + } as unknown as $TSContext; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('updateWalkthrough() test to add gsi', async () => { + let mockAmplifyMeta = { + storage: { + mockresourcename: { + service: 'DynamoDB', + providerPlugin: 'awscloudformation', + }, + dynamoefb50875: { + service: 'DynamoDB', + providerPlugin: 'awscloudformation', + }, + }, + }; + + stateManager.getMeta = jest.fn().mockReturnValue(mockAmplifyMeta); + + const currentCLIInputsJSON: DynamoDBCLIInputs = { + resourceName: 'mockresourcename', + tableName: 'mocktablename', + partitionKey: { + fieldName: 'id', + fieldType: FieldType.string, + }, + sortKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + gsi: [ + { + name: 'gsiname', + partitionKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + }, + ], + triggerFunctions: [], + }; + + jest.spyOn(DynamoDBInputState.prototype, 'getCliInputPayload').mockImplementation(() => currentCLIInputsJSON); + + jest.spyOn(DynamoDBInputState.prototype, 'saveCliInputPayload').mockImplementation(() => true); + jest.spyOn(DynamoDBInputState.prototype, 'cliInputFileExists').mockImplementation(() => true); + jest.spyOn(DDBStackTransform.prototype, 'transform').mockImplementation(() => Promise.resolve()); + + prompter.input = jest + .fn() + .mockResolvedValueOnce('col') // What would you like to name this column + .mockResolvedValueOnce('updategsiname'); // Provide the GSI name + + prompter.pick = jest + .fn() + .mockReturnValueOnce('mockresourcename') // Specify the resource that you would want to update + .mockReturnValueOnce('string') // Choose the data type + .mockReturnValueOnce('col') // Choose partition key for the GSI + .mockReturnValueOnce('name'); // Choose sort key for the GSI + + prompter.yesOrNo = jest + .fn() + .mockReturnValueOnce(true) // Would you like to add another column + .mockReturnValueOnce(false) // Would you like to add another column + .mockReturnValueOnce(true) // Do you want to keep existing global seconday indexes created on your table? + .mockReturnValueOnce(true) // Do you want to add global secondary indexes to your table? + .mockReturnValueOnce(false) // Do you want to add a sort key to your global secondary index + .mockReturnValueOnce(false); // Do you want to add more global secondary indexes to your table? + + prompter.confirmContinue = jest.fn().mockReturnValueOnce(false); // Do you want to add a Lambda Trigger for your Table? + + const returnedCLIInputs = await updateWalkthrough(mockContext); + + const expectedCLIInputsJSON: DynamoDBCLIInputs = { + resourceName: 'mockresourcename', + tableName: 'mocktablename', + partitionKey: { + fieldName: 'id', + fieldType: FieldType.string, + }, + sortKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + gsi: [ + { + name: 'gsiname', + partitionKey: { + fieldName: 'name', + fieldType: FieldType.number, + }, + }, + { + name: 'updategsiname', + partitionKey: { + fieldName: 'col', + fieldType: FieldType.string, + }, + }, + ], + triggerFunctions: [], + }; + + expect(returnedCLIInputs).toEqual(expectedCLIInputsJSON); + expect(DynamoDBInputState.prototype.saveCliInputPayload).toHaveBeenCalledWith(expectedCLIInputsJSON); + }); +}); diff --git a/packages/amplify-category-storage/src/commands/storage/override.ts b/packages/amplify-category-storage/src/commands/storage/override.ts index 4b56910d6a1..02371347487 100644 --- a/packages/amplify-category-storage/src/commands/storage/override.ts +++ b/packages/amplify-category-storage/src/commands/storage/override.ts @@ -2,10 +2,8 @@ entry code for amplify override root */ -import { generateOverrideSkeleton, $TSContext, FeatureFlags, stateManager, pathManager } from 'amplify-cli-core'; -import { printer } from 'amplify-prompts'; -import * as fs from 'fs-extra'; -import inquirer from 'inquirer'; +import { generateOverrideSkeleton, $TSContext, stateManager, pathManager } from 'amplify-cli-core'; +import { printer, prompter } from 'amplify-prompts'; import { DynamoDBInputState } from '../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; import { DDBStackTransform } from '../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; import * as path from 'path'; @@ -14,70 +12,55 @@ import { categoryName } from '../../constants'; export const name = 'override'; export const run = async (context: $TSContext) => { - if (FeatureFlags.getBoolean('overrides.project')) { - const { amplify } = context; - const amplifyMeta = stateManager.getMeta(); + const amplifyMeta = stateManager.getMeta(); - const storageResources: string[] = []; + const storageResources: string[] = []; + if (amplifyMeta[categoryName]) { Object.keys(amplifyMeta[categoryName]).forEach(resourceName => { storageResources.push(resourceName); }); + } - if (storageResources.length === 0) { - const errMessage = 'No resources to override. You need to add a resource.'; - printer.error(errMessage); - return; - } + if (storageResources.length === 0) { + const errMessage = 'No resources to override. You need to add a resource.'; + printer.error(errMessage); + return; + } - let selectedResourceName: string = storageResources[0]; + let selectedResourceName: string = storageResources[0]; - if (storageResources.length > 1) { - const resourceAnswer = await inquirer.prompt({ - type: 'list', - name: 'resource', - message: 'Which resource would you like to add overrides for?', - choices: storageResources, - }); - selectedResourceName = resourceAnswer.resource; - } + if (storageResources.length > 1) { + selectedResourceName = await prompter.pick('Which resource would you like to add overrides for?', storageResources); + } - const destPath = pathManager.getResourceDirectoryPath(undefined, categoryName, selectedResourceName); - fs.ensureDirSync(destPath); + const destPath = pathManager.getResourceDirectoryPath(undefined, categoryName, selectedResourceName); - const srcPath = path.join( - __dirname, - '..', - '..', - '..', - 'resources', - 'overrides-resource', - amplifyMeta[categoryName][selectedResourceName].service, - ); + const srcPath = path.join( + __dirname, + '..', + '..', + '..', + 'resources', + 'overrides-resource', + amplifyMeta[categoryName][selectedResourceName].service, + ); - // Make sure to migrate first - if (amplifyMeta[categoryName][selectedResourceName].service === 'DynamoDB') { - const resourceInputState = new DynamoDBInputState(selectedResourceName); - if (!resourceInputState.cliInputFileExists()) { - if (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) { - resourceInputState.migrate(); - const stackGenerator = new DDBStackTransform(selectedResourceName); - stackGenerator.transform(); - } else { - return; - } + // Make sure to migrate first + if (amplifyMeta[categoryName][selectedResourceName].service === 'DynamoDB') { + const resourceInputState = new DynamoDBInputState(selectedResourceName); + if (!resourceInputState.cliInputFileExists()) { + if (await prompter.yesOrNo('File migration required to continue. Do you want to continue?', true)) { + resourceInputState.migrate(); + const stackGenerator = new DDBStackTransform(selectedResourceName); + stackGenerator.transform(); + } else { + return; } - } else if (amplifyMeta[categoryName][selectedResourceName].service === 'S3') { - // S3 migration logic goes in here } - - await generateOverrideSkeleton(context, srcPath, destPath); - } else { - printer.info('Storage overrides is currently not turned on. In amplify/cli.json file please include the following:'); - printer.info(`{ - override: { - storage: true - } - }`); + } else if (amplifyMeta[categoryName][selectedResourceName].service === 'S3') { + // S3 migration logic goes in here } + + await generateOverrideSkeleton(context, srcPath, destPath); }; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts index 2934af82a24..80befacebd8 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import uuid from 'uuid'; import { alphanumeric, printer, prompter, Validator } from 'amplify-prompts'; -import { $TSContext, AmplifyCategories, ResourceDoesNotExistError, exitOnNextTick } from 'amplify-cli-core'; +import { $TSContext, AmplifyCategories, ResourceDoesNotExistError, exitOnNextTick, stateManager } from 'amplify-cli-core'; import { DynamoDBInputState } from './dynamoDB-input-state'; import { DynamoDBAttributeDefType, @@ -11,19 +11,20 @@ import { DynamoDBCLIInputsKeyType, } from '../service-walkthrough-types/dynamoDB-user-input-types'; import { DDBStackTransform } from '../cdk-stack-builder/ddb-stack-transform'; +import { ConfigSnapshotDeliveryProperties } from 'cloudform-types/types/config/deliveryChannel'; // keep in sync with ServiceName in amplify-AmplifyCategories.STORAGE-function, but probably it will not change const FunctionServiceNameLambdaFunction = 'Lambda'; const serviceName = 'DynamoDB'; -async function addWalkthrough(context: $TSContext, defaultValuesFilename: string) { +export async function addWalkthrough(context: $TSContext, defaultValuesFilename: string) { printer.blankLine(); printer.info('Welcome to the NoSQL DynamoDB database wizard'); printer.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); printer.blankLine(); const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); - const { getAllDefaults } = require(defaultValuesSrc); + const { getAllDefaults } = await import(defaultValuesSrc); const { amplify } = context; const defaultValues = getAllDefaults(amplify.getProjectDetails()); @@ -55,11 +56,8 @@ async function addWalkthrough(context: $TSContext, defaultValuesFilename: string return cliInputs.resourceName; } -async function updateWalkthrough(context: $TSContext) { - // const resourceName = resourceAlreadyExists(context); - const { amplify } = context; - const { amplifyMeta } = amplify.getProjectDetails(); - +export async function updateWalkthrough(context: $TSContext) { + const amplifyMeta = stateManager.getMeta(); const dynamoDbResources: any = {}; Object.keys(amplifyMeta[AmplifyCategories.STORAGE]).forEach(resourceName => { @@ -232,7 +230,7 @@ async function askGSIQuestion( gsiList = existingGSIList; } - if (await prompter.confirmContinue('Do you want to add global secondary indexes to your table?')) { + if (await prompter.yesOrNo('Do you want to add global secondary indexes to your table?', true)) { let continuewithGSIQuestions = true; while (continuewithGSIQuestions) { @@ -264,12 +262,11 @@ async function askGSIQuestion( const sortKeyOptions = indexableAttributeList.filter((att: string) => att !== gsiPartitionKeyName); if (sortKeyOptions.length > 0) { - if (await prompter.confirmContinue('Do you want to add a sort key to your global secondary index?')) { + if (await prompter.yesOrNo('Do you want to add a sort key to your global secondary index?', true)) { const gsiSortKeyName = await prompter.pick('Choose sort key for the GSI', [...new Set(sortKeyOptions)]); const gsiSortKeyIndex = attributeDefinitions.findIndex( (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiSortKeyName, ); - gsiItem.sortKey = { fieldName: gsiSortKeyName, fieldType: attributeDefinitions[gsiSortKeyIndex].AttributeType, @@ -278,7 +275,7 @@ async function askGSIQuestion( } gsiList.push(gsiItem); - continuewithGSIQuestions = await prompter.confirmContinue('Do you want to add more global secondary indexes to your table?'); + continuewithGSIQuestions = await prompter.yesOrNo('Do you want to add more global secondary indexes to your table?', true); } else { printer.error('You do not have any other attributes remaining to configure'); break; @@ -293,7 +290,7 @@ async function askSortKeyQuestion( attributeDefinitions: DynamoDBAttributeDefType[], partitionKeyFieldName: string, ): Promise { - if (await prompter.confirmContinue('Do you want to add a sort key to your table?')) { + if (await prompter.yesOrNo('Do you want to add a sort key to your table?', true)) { // Ask for sort key if (attributeDefinitions.length > 1) { const sortKeyName = await prompter.pick( @@ -369,7 +366,7 @@ async function askAttributeListQuestion(existingAttributeDefinitions?: DynamoDBC if (existingAttributes.length > 0) { attributeAnswers = existingAttributes; indexableAttributeList = attributeAnswers.map((attr: DynamoDBAttributeDefType) => attr.AttributeName); - continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); + continueAttributeQuestion = await prompter.yesOrNo('Would you like to add another column?', true); } while (continueAttributeQuestion) { @@ -400,7 +397,7 @@ async function askAttributeListQuestion(existingAttributeDefinitions?: DynamoDBC indexableAttributeList.push(attributeName); } - continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); + continueAttributeQuestion = await prompter.yesOrNo('Would you like to add another column?', true); } return { attributeAnswers, indexableAttributeList }; @@ -412,7 +409,7 @@ async function askTableNameQuestion(defaultValues: any, resourceName: string) { (input: string) => /^[a-zA-Z0-9._-]+$/.test(input) ? true : message; - const tableName = await prompter.input('Provide table name:', { + const tableName = await prompter.input('Provide table name', { validate: tableNameValidator('You can use the following characters: a-z A-Z 0-9 . - _'), initial: resourceName || defaultValues['tableName'], }); @@ -421,13 +418,10 @@ async function askTableNameQuestion(defaultValues: any, resourceName: string) { } async function askResourceNameQuestion(defaultValues: any): Promise { - const resourceName = await prompter.input( - 'Provide a friendly name for your resource that will be used to label this category in the project', - { - validate: alphanumeric(), - initial: defaultValues['resourceName'], - }, - ); + const resourceName = await prompter.input('Provide a friendly name', { + validate: alphanumeric(), + initial: defaultValues['resourceName'], + }); return resourceName; } diff --git a/packages/amplify-e2e-core/src/categories/storage.ts b/packages/amplify-e2e-core/src/categories/storage.ts index a5f111caf4b..6adad6021d2 100644 --- a/packages/amplify-e2e-core/src/categories/storage.ts +++ b/packages/amplify-e2e-core/src/categories/storage.ts @@ -17,17 +17,23 @@ export function addSimpleDDB(cwd: string, settings: any): Promise { spawn(getCLIPath(), ['add', 'storage'], { cwd, stripColors: true }) .wait('Please select from one of the below mentioned services') .sendLine(KEY_DOWN_ARROW) - .wait('Please provide a friendly name for your resource') - .sendLine(settings.name || '') - .wait('Please provide table name') + .wait('Provide a friendly name') + .sendLine(settings.name || '\r') + .wait('Provide table name') .sendCarriageReturn() .wait('What would you like to name this column') .sendLine('id') - .wait('Please choose the data type') + .wait('Choose the data type') + .sendCarriageReturn() + .wait('Would you like to add another column') + .sendLine('y') + .wait('What would you like to name this column') + .sendLine('col2') + .wait('Choose the data type') .sendCarriageReturn() .wait('Would you like to add another column') .sendLine('n') - .wait('Please choose partition key for the table') + .wait('Choose partition key for the table') .sendCarriageReturn() .wait('Do you want to add a sort key to your table') .sendLine('n') @@ -51,7 +57,7 @@ export function addDDBWithTrigger(cwd: string, settings: { ddbResourceName?: str const chain = spawn(getCLIPath(), ['add', 'storage'], { cwd, stripColors: true }) .wait('Please select from one of the below mentioned services') .sendLine(KEY_DOWN_ARROW) - .wait('Please provide a friendly name for your resource'); + .wait('Provide a friendly name'); if (settings.ddbResourceName) { chain.sendLine(settings.ddbResourceName); } else { @@ -59,15 +65,21 @@ export function addDDBWithTrigger(cwd: string, settings: { ddbResourceName?: str } chain .sendCarriageReturn() - .wait('Please provide table name') + .wait('Provide table name') .sendCarriageReturn() .wait('What would you like to name this column') .sendLine('id') - .wait('Please choose the data type') + .wait('Choose the data type') + .sendCarriageReturn() + .wait('Would you like to add another column') + .sendLine('y') + .wait('What would you like to name this column') + .sendLine('col2') + .wait('Choose the data type') .sendCarriageReturn() .wait('Would you like to add another column') .sendLine('n') - .wait('Please choose partition key for the table') + .wait('Choose partition key for the table') .sendCarriageReturn() .wait('Do you want to add a sort key to your table') .sendLine('n') @@ -107,8 +119,6 @@ export function updateDDBWithTrigger(cwd: string, settings: any): Promise .sendLine(KEY_DOWN_ARROW) .wait('Do you want to edit the local') .sendLine('n') - .wait('overwrite') - .sendLine('y') .sendEof() .run((err: Error) => { if (!err) { @@ -132,15 +142,17 @@ export function updateSimpleDDBwithGSI(cwd: string, settings: any): Promise { if (!err) { @@ -171,31 +179,31 @@ export function addSimpleDDBwithGSI(cwd: string, settings: any): Promise { .wait('Please select from one of the below mentioned services') .send(KEY_DOWN_ARROW) .sendCarriageReturn() - .wait('Please provide a friendly name for your resource') + .wait('Provide a friendly name') .sendCarriageReturn() - .wait('Please provide table name') + .wait('Provide table name') .sendCarriageReturn() .wait('What would you like to name this column') .sendLine('id') - .wait('Please choose the data type') + .wait('Choose the data type') .sendCarriageReturn() .wait('Would you like to add another column') .sendLine('y') .wait('What would you like to name this column') .sendLine('gsi-col1') - .wait('Please choose the data type') + .wait('Choose the data type') .sendCarriageReturn() .wait('Would you like to add another column') .sendLine('n') - .wait('Please choose partition key for the table') + .wait('Choose partition key for the table') .sendCarriageReturn() .wait('Do you want to add a sort key to your table') .sendLine('n') .wait('Do you want to add global secondary indexes to your table?') .sendLine('y') - .wait('Please provide the GSI name') + .wait('Provide the GSI name') .sendLine('gsi1') - .wait('Please choose partition key for the GSI') + .wait('Choose partition key for the GSI') .send(KEY_DOWN_ARROW) .sendCarriageReturn() .wait('Do you want to add a sort key to your global secondary index?') @@ -215,6 +223,115 @@ export function addSimpleDDBwithGSI(cwd: string, settings: any): Promise { }); } +export function overrideDDB(cwd: string, settings: {}) { + return new Promise((resolve, reject) => { + const args = ['override', 'storage']; + + spawn(getCLIPath(), args, { cwd, stripColors: true }) + .wait('Do you want to edit override.ts file now?') + .sendConfirmNo() + .sendEof() + .run((err: Error) => { + if (!err) { + resolve({}); + } else { + reject(err); + } + }); + }); +} + +export function buildOverrideStorage(cwd: string, settings: {}) { + return new Promise((resolve, reject) => { + // Add 'storage' as a category param once implemented + const args = ['build-override']; + + spawn(getCLIPath(), args, { cwd, stripColors: true }) + .sendEof() + .run((err: Error) => { + if (!err) { + resolve({}); + } else { + reject(err); + } + }); + }); +} + +export function addDynamoDBWithGSIWithSettings(projectDir: string, settings: AddDynamoDBSettings): Promise { + return new Promise((resolve, reject) => { + let chain = spawn(getCLIPath(), ['add', 'storage'], { cwd: projectDir, stripColors: true }); + + singleSelect(chain.wait('Please select from one of the below mentioned services:'), 'NoSQL Database', [ + 'Content (Images, audio, video, etc.)', + 'NoSQL Database', + ]); + + const addColumn = (name, type) => { + chain.wait('What would you like to name this column').sendLine(name); + + singleSelect(chain.wait('Choose the data type'), type, ['string', 'number', 'binary', 'boolean', 'list', 'map', 'null']); + }; + + const addAnotherColumn = () => { + chain.wait('Would you like to add another column').sendConfirmYes(); + }; + + chain.wait('Provide a friendly name').sendLine(settings.resourceName).wait('Provide table name').sendLine(settings.tableName); + + addColumn('pk', 'string'); + addAnotherColumn(); + + addColumn('sk', 'string'); + addAnotherColumn(); + + addColumn('gsi-pk', 'string'); + addAnotherColumn(); + + addColumn('gsi-sk', 'string'); + addAnotherColumn(); + + addColumn('title', 'string'); + addAnotherColumn(); + + addColumn('description', 'string'); + + chain.wait('Would you like to add another column').sendConfirmNo(); + + singleSelect(chain.wait('Choose the data type'), 'pk', ['pk', 'sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); + + chain.wait('Do you want to add a sort key to your table').sendConfirmYes(); + + singleSelect(chain.wait('Choose sort key for the table'), 'sk', ['sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); + + chain + .wait('Do you want to add global secondary indexes to your table?') + .sendConfirmYes() + .wait('Provide the GSI name') + .sendLine(settings.gsiName); + + singleSelect(chain.wait('Choose partition key for the GSI'), 'gsi-pk', ['sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); + + chain.wait('Do you want to add a sort key to your global secondary index').sendConfirmYes(); + + singleSelect(chain.wait('Choose sort key for the GSI'), 'gsi-sk', ['sk', 'gsi-sk', 'title', 'description']); + + chain + .wait('Do you want to add more global secondary indexes to your table') + .sendConfirmNo() + .wait('Do you want to add a Lambda Trigger for your Table') + .sendConfirmNo() + .sendEof() + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); + }); +} + export function addS3(cwd: string, settings: any): Promise { return new Promise((resolve, reject) => { spawn(getCLIPath(), ['add', 'storage'], { cwd, stripColors: true }) @@ -493,81 +610,3 @@ export function addS3StorageWithSettings(projectDir: string, settings: AddStorag }); }); } - -export function addDynamoDBWithGSIWithSettings(projectDir: string, settings: AddDynamoDBSettings): Promise { - return new Promise((resolve, reject) => { - let chain = spawn(getCLIPath(), ['add', 'storage'], { cwd: projectDir, stripColors: true }); - - singleSelect(chain.wait('Please select from one of the below mentioned services:'), 'NoSQL Database', [ - 'Content (Images, audio, video, etc.)', - 'NoSQL Database', - ]); - - const addColumn = (name, type) => { - chain.wait('What would you like to name this column').sendLine(name); - - singleSelect(chain.wait('Please choose the data type:'), type, ['string', 'number', 'binary', 'boolean', 'list', 'map', 'null']); - }; - - const addAnotherColumn = () => { - chain.wait('Would you like to add another column').sendConfirmYes(); - }; - - chain - .wait('Please provide a friendly name for your resource') - .sendLine(settings.resourceName) - .wait('Please provide table name') - .sendLine(settings.tableName); - - addColumn('pk', 'string'); - addAnotherColumn(); - - addColumn('sk', 'string'); - addAnotherColumn(); - - addColumn('gsi-pk', 'string'); - addAnotherColumn(); - - addColumn('gsi-sk', 'string'); - addAnotherColumn(); - - addColumn('title', 'string'); - addAnotherColumn(); - - addColumn('description', 'string'); - - chain.wait('Would you like to add another column').sendConfirmNo(); - - singleSelect(chain.wait('Please choose partition key for the table'), 'pk', ['pk', 'sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); - - chain.wait('Do you want to add a sort key to your table').sendConfirmYes(); - - singleSelect(chain.wait('Please choose sort key for the table'), 'sk', ['sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); - - chain - .wait('Do you want to add global secondary indexes to your table?') - .sendConfirmYes() - .wait('Please provide the GSI name') - .sendLine(settings.gsiName); - - singleSelect(chain.wait('Please choose partition key for the GSI'), 'gsi-pk', ['sk', 'gsi-pk', 'gsi-sk', 'title', 'description']); - - chain.wait('Do you want to add a sort key to your global secondary index').sendConfirmYes(); - - singleSelect(chain.wait('Please choose sort key for the GSI'), 'gsi-sk', ['sk', 'gsi-sk', 'title', 'description']); - - chain - .wait('Do you want to add more global secondary indexes to your table') - .sendConfirmNo() - .wait('Do you want to add a Lambda Trigger for your Table') - .sendConfirmNo() - .sendEof() - .run((err: Error) => { - if (!err) { - resolve(); - } else { - reject(err); - } - }); - }); -} diff --git a/packages/amplify-e2e-tests/Readme.md b/packages/amplify-e2e-tests/Readme.md index 9335c8cb261..77e5daccbbf 100644 --- a/packages/amplify-e2e-tests/Readme.md +++ b/packages/amplify-e2e-tests/Readme.md @@ -19,7 +19,7 @@ You can run a single test while adding a new test by running ```bash cd /packages/amplify-e2e-tests/ -npm run e2e __tests__/init.test.ts +npm run e2e src/__tests__/init.test.ts ``` ## Writing a new integration test diff --git a/packages/amplify-e2e-tests/overrides/override-storage-ddb.ts b/packages/amplify-e2e-tests/overrides/override-storage-ddb.ts new file mode 100644 index 00000000000..d80e8db9bda --- /dev/null +++ b/packages/amplify-e2e-tests/overrides/override-storage-ddb.ts @@ -0,0 +1,6 @@ +export function overrideProps(props: any) { + props.dynamoDBTable.streamSpecification = { + streamViewType: 'NEW_AND_OLD_IMAGES', + }; + return props; +} diff --git a/packages/amplify-e2e-tests/package.json b/packages/amplify-e2e-tests/package.json index cd9917f05c9..a7c8f486e2d 100644 --- a/packages/amplify-e2e-tests/package.json +++ b/packages/amplify-e2e-tests/package.json @@ -44,7 +44,8 @@ "yargs": "^15.1.0" }, "devDependencies": { - "ts-node": "^8.9.0" + "ts-node": "^8.9.0", + "jest": "^27.2.4" }, "jest": { "verbose": false, diff --git a/packages/amplify-e2e-tests/src/__tests__/storage.test.ts b/packages/amplify-e2e-tests/src/__tests__/storage.test.ts index 08d71dcfe06..efd4d818e5b 100644 --- a/packages/amplify-e2e-tests/src/__tests__/storage.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/storage.test.ts @@ -1,7 +1,10 @@ +import { JSONUtilities } from 'amplify-cli-core'; import { initJSProjectWithProfile, initFlutterProjectWithProfile, deleteProject, amplifyPushAuth } from 'amplify-e2e-core'; import { addAuthWithDefault, addAuthWithGroupsAndAdminAPI } from 'amplify-e2e-core'; import { addSimpleDDB, + overrideDDB, + buildOverrideStorage, addDDBWithTrigger, updateDDBWithTrigger, addSimpleDDBwithGSI, @@ -15,6 +18,7 @@ import { import { createNewProjectDir, deleteProjectDir, getProjectMeta, getDDBTable, checkIfBucketExists } from 'amplify-e2e-core'; import * as fs from 'fs-extra'; import * as path from 'path'; +import uuid from 'uuid'; describe('amplify add/update storage(S3)', () => { let projRoot: string; @@ -128,9 +132,12 @@ describe('amplify add/update storage(DDB)', () => { await amplifyPushAuth(projRoot); const meta = getProjectMeta(projRoot); - const { Name: table1Name, Arn: table1Arn, Region: table1Region, StreamArn: table1StreamArn } = Object.keys(meta.storage).map( - key => meta.storage[key], - )[0].output; + const { + Name: table1Name, + Arn: table1Arn, + Region: table1Region, + StreamArn: table1StreamArn, + } = Object.keys(meta.storage).map(key => meta.storage[key])[0].output; expect(table1Name).toBeDefined(); expect(table1Arn).toBeDefined(); @@ -140,9 +147,12 @@ describe('amplify add/update storage(DDB)', () => { expect(table1Configs.Table.TableArn).toEqual(table1Arn); - const { Name: table2Name, Arn: table2Arn, Region: table2Region, StreamArn: table2StreamArn } = Object.keys(meta.storage).map( - key => meta.storage[key], - )[1].output; + const { + Name: table2Name, + Arn: table2Arn, + Region: table2Region, + StreamArn: table2StreamArn, + } = Object.keys(meta.storage).map(key => meta.storage[key])[1].output; expect(table2Name).toBeDefined(); expect(table2Arn).toBeDefined(); @@ -152,3 +162,58 @@ describe('amplify add/update storage(DDB)', () => { expect(table2Configs.Table.TableArn).toEqual(table2Arn); }); }); + +describe('ddb override tests', () => { + let projRoot: string; + beforeEach(async () => { + projRoot = await createNewProjectDir('ddb-overrides'); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('override DDB StreamSpecification property', async () => { + const resourceName = `dynamo${uuid.v4().split('-')[0]}`; + await initJSProjectWithProfile(projRoot, {}); + await addSimpleDDB(projRoot, { name: resourceName }); + await overrideDDB(projRoot, {}); + + const srcOverrideFilePath = path.join(__dirname, '..', '..', 'overrides', 'override-storage-ddb.ts'); + const destOverrideFilePath = path.join(projRoot, 'amplify', 'backend', 'storage', resourceName, 'override.ts'); + const cfnFilePath = path.join(projRoot, 'amplify', 'backend', 'storage', resourceName, 'build', 'cloudformation-template.json'); + + fs.copyFileSync(srcOverrideFilePath, destOverrideFilePath); + + await buildOverrideStorage(projRoot, {}); + + let ddbCFNFileJSON: any = JSONUtilities.readJson(cfnFilePath); + + // check if overrides are applied to the cfn file + expect(ddbCFNFileJSON?.Resources?.DynamoDBTable?.Properties?.StreamSpecification?.StreamViewType).toEqual('NEW_AND_OLD_IMAGES'); + + await updateDDBWithTrigger(projRoot, {}); + + // check if override persists after an update + ddbCFNFileJSON = JSONUtilities.readJson(cfnFilePath); + expect(ddbCFNFileJSON?.Resources?.DynamoDBTable?.Properties?.StreamSpecification?.StreamViewType).toEqual('NEW_AND_OLD_IMAGES'); + + await amplifyPushAuth(projRoot); + + const meta = getProjectMeta(projRoot); + const { + Name: table1Name, + Arn: table1Arn, + Region: table1Region, + StreamArn: table1StreamArn, + } = Object.keys(meta.storage).map(key => meta.storage[key])[0].output; + + expect(table1Name).toBeDefined(); + expect(table1Arn).toBeDefined(); + expect(table1Region).toBeDefined(); + expect(table1StreamArn).toBeDefined(); + const table1Configs = await getDDBTable(table1Name, table1Region); + expect(table1Configs.Table.TableArn).toEqual(table1Arn); + }); +}); From 4e70aafbf448f192ce53340adaef0a9a4a48cafb Mon Sep 17 00:00:00 2001 From: Ghosh Date: Tue, 5 Oct 2021 13:52:43 -0700 Subject: [PATCH 7/7] chore: address pr comments --- packages/amplify-e2e-tests/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/amplify-e2e-tests/package.json b/packages/amplify-e2e-tests/package.json index a7c8f486e2d..5a6dbfb8599 100644 --- a/packages/amplify-e2e-tests/package.json +++ b/packages/amplify-e2e-tests/package.json @@ -44,8 +44,7 @@ "yargs": "^15.1.0" }, "devDependencies": { - "ts-node": "^8.9.0", - "jest": "^27.2.4" + "ts-node": "^8.10.2" }, "jest": { "verbose": false,