diff --git a/packages/amplify-category-api/.npmignore b/packages/amplify-category-api/.npmignore index 89eeb1ee92a..3af03e1d7c0 100644 --- a/packages/amplify-category-api/.npmignore +++ b/packages/amplify-category-api/.npmignore @@ -1,5 +1,6 @@ **/__mocks__/** **/__tests__/** ./src +!resources/overrides-resource/tsconfig.json tsconfig.json tsconfig.tsbuildinfo diff --git a/packages/amplify-category-api/amplify-plugin.json b/packages/amplify-category-api/amplify-plugin.json index 8bd97a6a3c7..f0a7bb3671a 100644 --- a/packages/amplify-category-api/amplify-plugin.json +++ b/packages/amplify-category-api/amplify-plugin.json @@ -1,7 +1,7 @@ { "name": "api", "type": "category", - "commands": ["add-graphql-datasource", "add", "console", "gql-compile", "push", "rebuild", "remove", "update", "help"], + "commands": ["add-graphql-datasource", "add", "console", "gql-compile", "override", "push", "rebuild", "remove", "update", "help"], "commandAliases": { "configure": "update" }, diff --git a/packages/amplify-category-api/package.json b/packages/amplify-category-api/package.json index fa76cb59454..8bf589116de 100644 --- a/packages/amplify-category-api/package.json +++ b/packages/amplify-category-api/package.json @@ -1,6 +1,6 @@ { - "name": "amplify-category-api", - "version": "2.33.2", + "name": "@aws-amplify/amplify-category-api", + "version": "1.0.0", "description": "amplify-cli api plugin", "repository": { "type": "git", @@ -14,7 +14,8 @@ "build": "tsc", "watch": "tsc -w", "clean": "rimraf lib tsconfig.tsbuildinfo", - "test": "jest" + "test": "jest", + "generateSchemas": "ts-node ./scripts/generateApiSchemas.ts" }, "dependencies": { "@aws-cdk/assets": "~1.124.0", @@ -78,7 +79,7 @@ "js-yaml": "^4.0.0", "lodash": "^4.17.21", "ora": "^4.0.3", - "uuid": "^3.4.0" + "uuid": "^8.3.2" }, "devDependencies": { "@types/js-yaml": "^4.0.0" diff --git a/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs b/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs deleted file mode 100644 index 959fb43c00d..00000000000 --- a/packages/amplify-category-api/resources/awscloudformation/cloudformation-templates/apigw-cloudformation-template-default.json.ejs +++ /dev/null @@ -1,512 +0,0 @@ -{ - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "API Gateway resource stack creation using Amplify CLI", - <% if (props.dependsOn) { %> - "Parameters": { - "env": { - "Type": "String" - }<%if (props.dependsOn && props.dependsOn.length > 0) { %>,<% } %> - <% for(var i=0; i < props.dependsOn.length; i++) { %> - <% for(var j=0; j < props.dependsOn[i].attributes.length; j++) { %> - "<%= props.dependsOn[i].category %><%= props.dependsOn[i].resourceName %><%= props.dependsOn[i].attributes[j] %>": { - "Type": "String", - "Default": "<%= props.dependsOn[i].category %><%= props.dependsOn[i].resourceName %><%= props.dependsOn[i].attributes[j] %>" - }<%if (i !== props.dependsOn.length - 1 || j !== props.dependsOn[i].attributes.length - 1) { %>,<% } %> - - <% } %> - <% } %> - <% } %> - }, - "Conditions": { - "ShouldNotCreateEnvResources": { - "Fn::Equals": [ - { - "Ref": "env" - }, - "NONE" - ] - } - }, - "Resources": { - <% for(var i=0; i < props.paths.length; i++) { %> - <%if (props.paths[i].privacy && props.paths[i].privacy.userPoolGroups) { %> - <% let selectedUserPoolGroupList = Object.keys(props.paths[i].privacy.userPoolGroups); %> - <% for(var j=0; j < selectedUserPoolGroupList.length; j++) { %> - "<%=selectedUserPoolGroupList[j]%>Group<%= props.paths[i].name.replace(/[^-a-z0-9]/g, '')%>Policy": { - "DependsOn": [ - "<%= props.apiName %>" - ], - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyName": "<%= props.apiName %>-<%= props.paths[i].name.replace(/[^-a-z0-9]/g, '')%>-<%=selectedUserPoolGroupList[j]%>-group-policy", - "Roles": [ - { - "Fn::Join": [ - "", - [ - { - "Ref": "auth<%= props.authResourceName%>UserPoolId" - }, - "-<%=selectedUserPoolGroupList[j]%>GroupRole" - ] - ] - } - ], - "PolicyDocument": { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "execute-api:Invoke" - ], - "Resource": [ - - <% for(var x=0; x < props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]].length; x++) { %> - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]][x] %>", - "<%= props.paths[i].policyResourceName %>/*" - ] - ] - }, - { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/", - { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "<%= props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]][x] %>", - "<%= props.paths[i].policyResourceName %>" - ] - ] - } - <% if (x !== props.paths[i].privacy.userPoolGroups[selectedUserPoolGroupList[j]].length - 1) { %> - , - <% } %> - <% } %> - ] - } - ] - } - } - }, - <% } %> - <% } %> - <% } %> - "<%= props.apiName %>": { - "Type": "AWS::ApiGateway::RestApi", - "Properties": { - "Description": "", - "Name": "<%= props.apiName %>", - "Body": { - "swagger": "2.0", - "info": { - "version": "2018-05-24T17:52:00Z", - "title": "<%= props.apiName %>" - }, - "host": { - "Fn::Join": [ - "", - [ - "apigateway.", - { - "Ref": "AWS::Region" - }, - ".amazonaws.com" - ] - ] - }, - "basePath": { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "/Prod", - { - "Fn::Join": [ - "", - [ - "/", - { - "Ref": "env" - } - ] - ] - } - ] - }, - "schemes": [ - "https" - ], - "paths": { - <% for(var i=0; i < props.paths.length; i++) { %> - "<%= props.paths[i].name %>": { - "options": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "responses": { - "200": { - "description": "200 response", - "headers": { - "Access-Control-Allow-Origin": { - "type": "string" - }, - "Access-Control-Allow-Methods": { - "type": "string" - }, - "Access-Control-Allow-Headers": { - "type": "string" - } - } - } - }, - "x-amazon-apigateway-integration": { - "responses": { - "default": { - "statusCode": "200", - "responseParameters": { - "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", - "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", - "method.response.header.Access-Control-Allow-Origin": "'*'" - } - } - }, - "requestTemplates": { - "application/json": "{\"statusCode\": 200}" - }, - "passthroughBehavior": "when_no_match", - "type": "mock" - } - }, - "x-amazon-apigateway-any-method": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "parameters": [ - { - "in": "body", - "name": "RequestSchema", - "required": false, - "schema": { - "$ref": "#/definitions/RequestSchema" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "schema": { - "$ref": "#/definitions/ResponseSchema" - } - } - }, - <%if (!props.paths[i].privacy.open) { %> - "security": [ - { - "sigv4": [] - } - ], - <% } %> - "x-amazon-apigateway-integration": { - "responses": { - "default": { - "statusCode": "200" - } - }, - "uri": { - "Fn::Join": [ - "", - [ - "arn:aws:apigateway:", - { - "Ref": "AWS::Region" - }, - ":lambda:path/2015-03-31/functions/", - <% if (props.paths[i].lambdaArn ) { %> - "<%= props.paths[i].lambdaArn %>", - <% } else { %> - { - - "Ref": "function<%= props.paths[i].lambdaFunction %>Arn" - }, - <% } %> - "/invocations" - ] - ] - }, - "passthroughBehavior": "when_no_match", - "httpMethod": "POST", - "type": "aws_proxy" - } - } - }, - "<%= props.paths[i].name %>/{proxy+}": { - "options": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "responses": { - "200": { - "description": "200 response", - "headers": { - "Access-Control-Allow-Origin": { - "type": "string" - }, - "Access-Control-Allow-Methods": { - "type": "string" - }, - "Access-Control-Allow-Headers": { - "type": "string" - } - } - } - }, - "x-amazon-apigateway-integration": { - "responses": { - "default": { - "statusCode": "200", - "responseParameters": { - "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", - "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", - "method.response.header.Access-Control-Allow-Origin": "'*'" - } - } - }, - "requestTemplates": { - "application/json": "{\"statusCode\": 200}" - }, - "passthroughBehavior": "when_no_match", - "type": "mock" - } - }, - "x-amazon-apigateway-any-method": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "parameters": [ - { - "in": "body", - "name": "RequestSchema", - "required": false, - "schema": { - "$ref": "#/definitions/RequestSchema" - } - } - ], - "responses": { - "200": { - "description": "200 response", - "schema": { - "$ref": "#/definitions/ResponseSchema" - } - } - }, - <%if (!props.paths[i].privacy.open) { %> - "security": [ - { - "sigv4": [] - } - ], - <% } %> - "x-amazon-apigateway-integration": { - "responses": { - "default": { - "statusCode": "200" - } - }, - "uri": { - "Fn::Join": [ - "", - [ - "arn:aws:apigateway:", - { - "Ref": "AWS::Region" - }, - ":lambda:path/2015-03-31/functions/", - <% if (props.paths[i].lambdaArn) { %> - "<%= props.paths[i].lambdaArn %>", - <% } else { %> - { - - "Ref": "function<%= props.paths[i].lambdaFunction %>Arn" - }, - <% } %> - "/invocations" - ] - ] - }, - "passthroughBehavior": "when_no_match", - "httpMethod": "POST", - "type": "aws_proxy" - } - } - }<% if (i !== props.paths.length - 1) { %>,<% } %> - <% } %> - }, - "securityDefinitions": { - "sigv4": { - "type": "apiKey", - "name": "Authorization", - "in": "header", - "x-amazon-apigateway-authtype": "awsSigv4" - } - }, - "definitions": { - "RequestSchema": { - "type": "object", - "required": [ - "request" - ], - "properties": { - "request": { - "type": "string" - } - }, - "title": "Request Schema" - }, - "ResponseSchema": { - "type": "object", - "required": [ - "response" - ], - "properties": { - "response": { - "type": "string" - } - }, - "title": "Response Schema" - } - } - }, - "FailOnWarnings": true - } - }, - - <%if (props.functionArns) { %> - <% for (var i=0; i < props.functionArns.length; i++) { %> - - "function<%= props.functionArns[i].lambdaFunction.replace(/[^0-9a-zA-Z]/gi, '') %>Permission<%= props.apiName %>": { - "Type": "AWS::Lambda::Permission", - "Properties": { - "FunctionName": <% if (props.functionArns[i].lambdaArn) {%> "<%= props.functionArns[i].lambdaArn %>", <% } else { %> - { - "Ref": "function<%= props.functionArns[i].lambdaFunction %>Name" - }, - <% } %> - "Action": "lambda:InvokeFunction", - "Principal": "apigateway.amazonaws.com", - "SourceArn": { - "Fn::Join": [ - "", - [ - "arn:aws:execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "<%= props.apiName %>" - }, - "/*/*/*" - ] - ] - } - } - }, - <% } %> - <% } %> - - "DeploymentAPIGW<%= props.apiName %><%= props.uuid %>": { - "Type": "AWS::ApiGateway::Deployment", - "Properties": { - "Description": "The Development stage deployment of your API.", - "StageName": { - "Fn::If": [ - "ShouldNotCreateEnvResources", - "Prod", - { - "Ref": "env" - } - ] - }, - "RestApiId": { - "Ref": "<%= props.apiName %>" - } - } - } - }, - "Outputs": { - "RootUrl": { - "Description": "Root URL of the API gateway", - "Value": {"Fn::Join": ["", ["https://", {"Ref": "<%= props.apiName %>"}, ".execute-api.", {"Ref": "AWS::Region"}, ".amazonaws.com/", {"Fn::If": ["ShouldNotCreateEnvResources","Prod", {"Ref": "env"} ]}]]} - }, - "ApiName": { - "Description": "API Friendly name", - "Value": "<%= props.resourceName %>" - }, - "ApiId": { - "Description": "API ID (prefix of API URL)", - "Value": {"Ref": "<%= props.apiName %>"} - } - } - } diff --git a/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/override.ts b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/override.ts new file mode 100644 index 00000000000..ac3781476a1 --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/override.ts @@ -0,0 +1,8 @@ +// This file is used to override the REST API resource configuration +// import { AmplifyApigwResourceTemplate } from '@aws-amplify/cli-overrides-helper'; + +/* TODO: Need to change props to Root-Stack specific props when props are ready */ +export function overrideProps(props: any) { + /* Override props (AmplifyApigwResourceTemplate) with new parameters */ + return props; +} diff --git a/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/package.json b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/package.json new file mode 100644 index 00000000000..61f6fd48bc7 --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/package.json @@ -0,0 +1,17 @@ +{ + "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": { + "fs-extra": "^9.1.0" + }, + "devDependencies": { + "@types/fs-extra": "^9.0.11", + "typescript": "^4.2.4" + } +} diff --git a/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/tsconfig.json b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/tsconfig.json new file mode 100644 index 00000000000..c6f1a33b4d9 --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + } +} diff --git a/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/tsconfig.resource.json b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/tsconfig.resource.json new file mode 100644 index 00000000000..6504da80283 --- /dev/null +++ b/packages/amplify-category-api/resources/awscloudformation/overrides-resource/APIGW/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-api/resources/schemas/aPIGateway/APIGatewayCLIInputs.schema.json b/packages/amplify-category-api/resources/schemas/aPIGateway/APIGatewayCLIInputs.schema.json new file mode 100644 index 00000000000..a0b2cbe39be --- /dev/null +++ b/packages/amplify-category-api/resources/schemas/aPIGateway/APIGatewayCLIInputs.schema.json @@ -0,0 +1,68 @@ +{ + "description": "Defines the json object expected by the amplify api category", + "type": "object", + "properties": { + "version": { + "description": "The schema version.", + "type": "number", + "enum": [1] + }, + "paths": { + "description": "map of paths in the REST API.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "lambdaFunction": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": { + "setting": { + "$ref": "#/definitions/PermissionSetting" + }, + "auth": { + "type": "array", + "items": { + "enum": ["CREATE", "DELETE", "READ", "UPDATE"], + "type": "string" + } + }, + "guest": { + "type": "array", + "items": { + "enum": ["CREATE", "DELETE", "READ", "UPDATE"], + "type": "string" + } + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "enum": ["CREATE", "DELETE", "READ", "UPDATE"], + "type": "string" + } + } + } + } + }, + "required": ["setting"] + } + }, + "required": ["lambdaFunction", "permissions"] + } + } + }, + "required": ["paths", "version"], + "definitions": { + "PermissionSetting": { + "enum": ["open", "private", "protected"], + "type": "string" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/amplify-category-api/scripts/generateApiSchemas.ts b/packages/amplify-category-api/scripts/generateApiSchemas.ts new file mode 100644 index 00000000000..e611a1a84b9 --- /dev/null +++ b/packages/amplify-category-api/scripts/generateApiSchemas.ts @@ -0,0 +1,14 @@ +import * as SchemaGenerator from 'amplify-cli-core'; + +type TypeDef = SchemaGenerator.TypeDef; + +const ApigwTypeDef: TypeDef = { + typeName: 'APIGatewayCLIInputs', + service: 'API Gateway', +}; + +// Defines the type names and the paths to the TS files that define them +const apigwCategoryTypeDefs: TypeDef[] = [ApigwTypeDef]; + +const schemaGenerator = new SchemaGenerator.CLIInputSchemaGenerator(apigwCategoryTypeDefs); +schemaGenerator.generateJSONSchemas(); // convert CLI input data into json schemas. diff --git a/packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.js b/packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.ts similarity index 87% rename from packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.js rename to packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.ts index 701aadaa8c5..b0acae414dd 100644 --- a/packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.js +++ b/packages/amplify-category-api/src/__tests__/commands/api/add-graphql-datasource.test.ts @@ -1,5 +1,5 @@ -const { readSchema } = require('../../../commands/api/add-graphql-datasource'); -const path = require('path'); +import { readSchema } from '../../../commands/api/add-graphql-datasource'; +import * as path from 'path'; describe('read schema', () => { it('Valid schema present in folder', async () => { diff --git a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/cfn-api-artifact-handler.test.ts b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/cfn-api-artifact-handler.test.ts index e8dfa51770a..1d3cbeefbfc 100644 --- a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/cfn-api-artifact-handler.test.ts +++ b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/cfn-api-artifact-handler.test.ts @@ -1,19 +1,23 @@ -import path from 'path'; -import fs from 'fs-extra'; -import { ApiArtifactHandler } from '../../../provider-utils/api-artifact-handler'; -import { getCfnApiArtifactHandler } from '../../../provider-utils/awscloudformation/cfn-api-artifact-handler'; +import { $TSContext, pathManager, stateManager } from 'amplify-cli-core'; import { AddApiRequest, UpdateApiRequest } from 'amplify-headless-interface'; -import { category } from '../../../category-constants'; +import { printer } from 'amplify-prompts'; +import * as fs from 'fs-extra'; import { writeTransformerConfiguration } from 'graphql-transformer-core'; +import _ from 'lodash'; +import * as path from 'path'; +import { category } from '../../../category-constants'; +import { ApiArtifactHandler } from '../../../provider-utils/api-artifact-handler'; import { rootAssetDir } from '../../../provider-utils/awscloudformation/aws-constants'; +import { getCfnApiArtifactHandler } from '../../../provider-utils/awscloudformation/cfn-api-artifact-handler'; import { - getAppSyncResourceName, - getAppSyncAuthConfig, authConfigHasApiKey, + getAppSyncAuthConfig, + getAppSyncResourceName, } from '../../../provider-utils/awscloudformation/utils/amplify-meta-utils'; -import _ from 'lodash'; jest.mock('fs-extra'); +const printer_mock = printer as jest.Mocked; +printer_mock.warn = jest.fn(); jest.mock('graphql-transformer-core', () => ({ readTransformerConfiguration: jest.fn(async () => ({})), @@ -30,32 +34,26 @@ jest.mock('../../../provider-utils/awscloudformation/utils/amplify-meta-utils', jest.mock('amplify-cli-core'); +const backendDirPathStub = 'backendDirPath'; +const testApiName = 'testApiName'; + +const pathManager_mock = pathManager as jest.Mocked; +pathManager_mock.getResourceDirectoryPath = jest.fn().mockReturnValue(`${backendDirPathStub}/api/${testApiName}`); +const stateManager_mock = stateManager as jest.Mocked; + const fs_mock = fs as unknown as jest.Mocked; const writeTransformerConfiguration_mock = writeTransformerConfiguration as jest.MockedFunction; const getAppSyncResourceName_mock = getAppSyncResourceName as jest.MockedFunction; const getAppSyncAuthConfig_mock = getAppSyncAuthConfig as jest.MockedFunction; const authConfigHasApiKey_mock = authConfigHasApiKey as jest.MockedFunction; -const backendDirPathStub = 'backendDirPath'; - -const testApiName = 'testApiName'; - const context_stub = { - print: { - success: jest.fn(), - warning: jest.fn(), - }, amplify: { updateamplifyMetaAfterResourceAdd: jest.fn(), updateamplifyMetaAfterResourceUpdate: jest.fn(), updateBackendConfigAfterResourceUpdate: jest.fn(), executeProviderUtils: jest.fn(), copyBatch: jest.fn(), - getProjectMeta: jest.fn(), - readJsonFile: jest.fn(), - pathManager: { - getBackendDirPath: jest.fn(() => backendDirPathStub), - }, }, }; @@ -80,7 +78,7 @@ describe('create artifacts', () => { }); beforeEach(() => { jest.clearAllMocks(); - cfnApiArtifactHandler = getCfnApiArtifactHandler(context_stub); + cfnApiArtifactHandler = getCfnApiArtifactHandler(context_stub as unknown as $TSContext); }); it('does not create a second API if one already exists', async () => { @@ -180,7 +178,7 @@ describe('update artifacts', () => { beforeEach(() => { jest.clearAllMocks(); updateRequestStub = _.cloneDeep(updateRequestStubBase); - cfnApiArtifactHandler = getCfnApiArtifactHandler(context_stub); + cfnApiArtifactHandler = getCfnApiArtifactHandler(context_stub as unknown as $TSContext); }); it('throws error if no GQL API in project', () => { @@ -236,12 +234,12 @@ describe('update artifacts', () => { it('prints warning when adding API key auth', async () => { authConfigHasApiKey_mock.mockImplementationOnce(() => false).mockImplementationOnce(() => true); await cfnApiArtifactHandler.updateArtifacts(updateRequestStub); - expect(context_stub.print.warning.mock.calls.length).toBe(2); + expect(printer_mock.warn.mock.calls.length).toBe(2); }); it('prints warning when removing API key auth', async () => { authConfigHasApiKey_mock.mockImplementationOnce(() => true).mockImplementationOnce(() => false); await cfnApiArtifactHandler.updateArtifacts(updateRequestStub); - expect(context_stub.print.warning.mock.calls.length).toBe(3); + expect(printer_mock.warn.mock.calls.length).toBe(3); }); }); diff --git a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-add-resource.test.ts b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-add-resource.test.ts index 4dc7ed152f7..ada52d192ce 100644 --- a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-add-resource.test.ts +++ b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-add-resource.test.ts @@ -1,22 +1,29 @@ import { legacyAddResource } from '../../../provider-utils/awscloudformation/legacy-add-resource'; import { category } from '../../../category-constants'; +import { $TSAny, $TSContext } from 'amplify-cli-core'; jest.mock('fs-extra'); -jest.mock('amplify-cli-core'); +jest.mock('amplify-cli-core', () => ({ + isResourceNameUnique: jest.fn().mockReturnValue(true), + JSONUtilities: { + readJson: jest.fn(), + writeJson: jest.fn(), + }, + pathManager: { + getResourceDirectoryPath: jest.fn(_ => 'mock/backend/path'), + }, +})); describe('legacy add resource', () => { const contextStub = { amplify: { - pathManager: { - getBackendDirPath: jest.fn(_ => 'mock/backend/path'), - }, updateamplifyMetaAfterResourceAdd: jest.fn(), copyBatch: jest.fn(), }, }; it('sets policy resource name in paths object before copying template', async () => { - const stubWalkthroughPromise: Promise = Promise.resolve({ + const stubWalkthroughPromise: Promise<$TSAny> = Promise.resolve({ answers: { resourceName: 'mockResourceName', paths: [ @@ -29,7 +36,7 @@ describe('legacy add resource', () => { ], }, }); - await legacyAddResource(stubWalkthroughPromise, contextStub, category, 'API Gateway', {}); + await legacyAddResource(stubWalkthroughPromise, contextStub as unknown as $TSContext, category, 'API Gateway', {}); expect(contextStub.amplify.copyBatch.mock.calls[0][2]).toMatchSnapshot(); }); }); diff --git a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-update-resource.test.ts b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-update-resource.test.ts index 135f62250fa..cf6e04f21fc 100644 --- a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-update-resource.test.ts +++ b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/legacy-update-resource.test.ts @@ -1,14 +1,21 @@ +import { $TSContext } from 'amplify-cli-core'; import { legacyUpdateResource } from '../../../provider-utils/awscloudformation/legacy-update-resource'; import { category } from '../../../category-constants'; jest.mock('fs-extra'); +jest.mock('amplify-cli-core', () => ({ + JSONUtilities: { + readJson: jest.fn(), + writeJson: jest.fn(), + }, + pathManager: { + getResourceDirectoryPath: jest.fn(_ => 'mock/backend/path'), + }, +})); describe('legacy update resource', () => { const contextStub = { amplify: { - pathManager: { - getBackendDirPath: jest.fn(_ => 'mock/backend/path'), - }, updateamplifyMetaAfterResourceUpdate: jest.fn(), copyBatch: jest.fn(), }, @@ -28,7 +35,7 @@ describe('legacy update resource', () => { ], }, }); - await legacyUpdateResource(stubWalkthroughPromise, contextStub, category, 'API Gateway'); + await legacyUpdateResource(stubWalkthroughPromise, contextStub as unknown as $TSContext, category, 'API Gateway'); expect(contextStub.amplify.copyBatch.mock.calls[0][2]).toMatchSnapshot(); }); }); diff --git a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.test.ts b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.test.ts index 6e6a75c8d8c..9b2db83f7b8 100644 --- a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.test.ts +++ b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.test.ts @@ -1,42 +1,51 @@ +import { $TSAny, $TSContext, FeatureFlags, pathManager, stateManager } from 'amplify-cli-core'; import { - getIAMPolicies, askAdditionalAuthQuestions, + getIAMPolicies, } from '../../../../provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough'; import { authConfigHasApiKey, getAppSyncAuthConfig } from '../../../../provider-utils/awscloudformation/utils/amplify-meta-utils'; -import { FeatureFlags } from 'amplify-cli-core'; + jest.mock('../../../../provider-utils/awscloudformation/utils/amplify-meta-utils', () => ({ getAppSyncAuthConfig: jest.fn(), authConfigHasApiKey: jest.fn(), })); jest.mock('amplify-cli-core'); +const stateManager_mock = stateManager as jest.Mocked; +stateManager_mock.getMeta = jest.fn(); + +const pathManager_mock = pathManager as jest.Mocked; +pathManager_mock.getResourceDirectoryPath = jest.fn().mockReturnValue('mocked/resource/path'); + const mockGetBoolean = FeatureFlags.getBoolean as jest.Mock; const authConfigHasApiKey_mock = authConfigHasApiKey as jest.MockedFunction; const getAppSyncAuthConfig_mock = getAppSyncAuthConfig as jest.MockedFunction; const confirmPromptFalse_mock = jest.fn(() => false); -const context_stub = (prompt: jest.Mock) => ({ - prompt: { - confirm: prompt, - }, - amplify: { - getProjectMeta: jest.fn(), - }, -}); +const context_stub = (prompt: jest.Mock) => + ({ + prompt: { + confirm: prompt, + }, + amplify: { + getProjectMeta: jest.fn(), + }, + } as unknown as $TSContext); type IAMArtifact = { attributes: string[]; - policy: any; + policy: $TSAny; }; describe('get IAM policies', () => { beforeEach(() => { jest.resetModules(); }); + it('does not include API key if none exists', async () => { mockGetBoolean.mockImplementationOnce(() => true); authConfigHasApiKey_mock.mockImplementationOnce(() => false); - const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query'], context_stub(confirmPromptFalse_mock)); + const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query']); expect(iamArtifact.attributes).toMatchInlineSnapshot(` Array [ "GraphQLAPIIdOutput", @@ -49,7 +58,7 @@ describe('get IAM policies', () => { it('includes API key if it exists', async () => { mockGetBoolean.mockImplementationOnce(() => true); authConfigHasApiKey_mock.mockImplementationOnce(() => true); - const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query'], context_stub(confirmPromptFalse_mock)); + const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query']); expect(iamArtifact.attributes).toMatchInlineSnapshot(` Array [ "GraphQLAPIIdOutput", @@ -63,7 +72,7 @@ describe('get IAM policies', () => { it('policy path includes the new format for graphql operations', async () => { mockGetBoolean.mockImplementationOnce(() => true); authConfigHasApiKey_mock.mockImplementationOnce(() => false); - const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query', 'Mutate'], context_stub(confirmPromptFalse_mock)); + const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['Query', 'Mutate']); expect(iamArtifact.attributes).toMatchInlineSnapshot(` Array [ "GraphQLAPIIdOutput", @@ -73,10 +82,11 @@ describe('get IAM policies', () => { expect(iamArtifact.policy.Resource[0]['Fn::Join'][1][6]).toMatch('/types/Query/*'); expect(iamArtifact.policy.Resource[1]['Fn::Join'][1][6]).toMatch('/types/Mutate/*'); }); + it('policy path includes the old format for appsync api operations', async () => { mockGetBoolean.mockImplementationOnce(() => false); authConfigHasApiKey_mock.mockImplementationOnce(() => false); - const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['create', 'update'], context_stub(confirmPromptFalse_mock)); + const iamArtifact: IAMArtifact = getIAMPolicies('testResourceName', ['create', 'update']); expect(iamArtifact.attributes).toMatchInlineSnapshot(` Array [ "GraphQLAPIIdOutput", diff --git a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/utils/rest-api-path-utils.test.ts b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/utils/rest-api-path-utils.test.ts index 7c82402f421..6facce6d56d 100644 --- a/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/utils/rest-api-path-utils.test.ts +++ b/packages/amplify-category-api/src/__tests__/provider-utils/awscloudformation/utils/rest-api-path-utils.test.ts @@ -4,7 +4,7 @@ import { formatCFNPathParamsForExpressJs, } from '../../../../provider-utils/awscloudformation/utils/rest-api-path-utils'; -const stubOtherPaths = [{ name: '/other/path' }, { name: '/sub/path' }, { name: '/path/{with}/{params}' }]; +const stubOtherPaths = ['/other/path', '/sub/path', '/path/{with}/{params}']; test('validatePathName_validPath', () => { expect(validatePathName('/some/path')).toBe(true); @@ -56,9 +56,9 @@ test('checkForPathOverlap_subPathParamsNoMatch', () => { }); test('checkForPathOverlap_pathMatch', () => { - expect(checkForPathOverlap(stubOtherPaths[0].name, stubOtherPaths)).toEqual({ - higherOrderPath: stubOtherPaths[0].name, - lowerOrderPath: stubOtherPaths[0].name, + expect(checkForPathOverlap(stubOtherPaths[0], stubOtherPaths)).toEqual({ + higherOrderPath: stubOtherPaths[0], + lowerOrderPath: stubOtherPaths[0], }); }); diff --git a/packages/amplify-category-api/src/commands/api.js b/packages/amplify-category-api/src/commands/api.js deleted file mode 100644 index e8c261e00df..00000000000 --- a/packages/amplify-category-api/src/commands/api.js +++ /dev/null @@ -1,55 +0,0 @@ -const featureName = 'api'; - -module.exports = { - name: featureName, - run: async context => { - if (/^win/.test(process.platform)) { - try { - const { run } = require(`./${featureName}/${context.parameters.first}`); - return run(context); - } catch (e) { - context.print.error('Command not found'); - } - } - const header = `amplify ${featureName} `; - const commands = [ - { - name: 'add', - description: `Takes you through a CLI flow to add a ${featureName} resource to your local backend`, - }, - { - name: 'push', - description: `Provisions ${featureName} cloud resources and its dependencies with the latest local developments`, - }, - { - name: 'remove', - description: `Removes ${featureName} resource from your local backend which would be removed from the cloud on the next push command`, - }, - { - name: 'update', - description: `Takes you through steps in the CLI to update an ${featureName} resource`, - }, - { - name: 'gql-compile', - description: 'Compiles your GraphQL schema and generates a corresponding cloudformation template', - }, - { - name: 'add-graphql-datasource', - description: 'Provisions the AppSync resources and its dependencies for the provided Aurora Serverless data source', - }, - { - name: 'console', - description: 'Opens the web console for the selected api service', - }, - { - name: 'rebuild', - description: - 'Removes and recreates all DynamoDB tables backing a GraphQL API. Useful for resetting test data during the development phase of an app', - }, - ]; - - context.amplify.showHelp(header, commands); - - context.print.info(''); - }, -}; diff --git a/packages/amplify-category-api/src/commands/api.ts b/packages/amplify-category-api/src/commands/api.ts new file mode 100644 index 00000000000..d9741de9a9b --- /dev/null +++ b/packages/amplify-category-api/src/commands/api.ts @@ -0,0 +1,60 @@ +import { $TSContext, AmplifyCategories } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as path from 'path'; + +export const name = AmplifyCategories.API; + +export const run = async (context: $TSContext) => { + if (/^win/.test(process.platform)) { + try { + const { run } = await import(path.join('.', AmplifyCategories.API, context.parameters.first)); + return run(context); + } catch (e) { + printer.error('Command not found'); + } + } + const header = `amplify ${AmplifyCategories.API} `; + const commands = [ + { + name: 'add', + description: `Takes you through a CLI flow to add a ${AmplifyCategories.API} resource to your local backend`, + }, + { + name: 'push', + description: `Provisions ${AmplifyCategories.API} cloud resources and its dependencies with the latest local developments`, + }, + { + name: 'remove', + description: `Removes ${AmplifyCategories.API} resource from your local backend which would be removed from the cloud on the next push command`, + }, + { + name: 'update', + description: `Takes you through steps in the CLI to update an ${AmplifyCategories.API} resource`, + }, + { + name: 'gql-compile', + description: 'Compiles your GraphQL schema and generates a corresponding cloudformation template', + }, + { + name: 'add-graphql-datasource', + description: 'Provisions the AppSync resources and its dependencies for the provided Aurora Serverless data source', + }, + { + name: 'console', + description: 'Opens the web console for the selected api service', + }, + { + name: 'rebuild', + description: + 'Removes and recreates all DynamoDB tables backing a GraphQL API. Useful for resetting test data during the development phase of an app', + }, + { + name: 'override', + description: 'Generates overrides file to apply custom modifications to CloudFormation', + }, + ]; + + context.amplify.showHelp(header, commands); + + printer.blankLine(); +}; diff --git a/packages/amplify-category-api/src/commands/api/add-graphql-datasource.ts b/packages/amplify-category-api/src/commands/api/add-graphql-datasource.ts index d4bd9be8d84..b9a235b802d 100644 --- a/packages/amplify-category-api/src/commands/api/add-graphql-datasource.ts +++ b/packages/amplify-category-api/src/commands/api/add-graphql-datasource.ts @@ -1,171 +1,169 @@ +import { mergeTypeDefs } from '@graphql-tools/merge'; +import { $TSAny, $TSContext, exitOnNextTick, FeatureFlags, pathManager, ResourceDoesNotExistError, stateManager } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; import * as fs from 'fs-extra'; -import * as path from 'path'; import * as graphql from 'graphql'; -import _ from 'lodash'; -import inquirer from 'inquirer'; import { + AuroraServerlessMySQLDatabaseReader, RelationalDBSchemaTransformer, RelationalDBTemplateGenerator, - AuroraServerlessMySQLDatabaseReader, } from 'graphql-relational-schema-transformer'; -import { mergeTypeDefs } from '@graphql-tools/merge'; -import { FeatureFlags, ResourceDoesNotExistError, exitOnNextTick, $TSAny, $TSContext, stateManager, pathManager } from 'amplify-cli-core'; +import inquirer from 'inquirer'; +import _ from 'lodash'; +import * as path from 'path'; const subcommand = 'add-graphql-datasource'; const categories = 'categories'; const category = 'api'; const providerName = 'awscloudformation'; -module.exports = { - name: subcommand, - run: async (context: $TSContext) => { - try { - const servicesMetadata = (await import('../../provider-utils/supported-datasources')).supportedDatasources; +export const name = subcommand; - const AWS = await getAwsClient(context, 'list'); +export const run = async (context: $TSContext) => { + try { + const servicesMetadata = (await import(path.join('..', '..', 'provider-utils', 'supported-services'))).supportedServices; + const AWS = await getAwsClient(context, 'list'); - const result: $TSAny = await datasourceSelectionPrompt(context, servicesMetadata); + const result: $TSAny = await datasourceSelectionPrompt(context, servicesMetadata); - const providerController = await import(`../../provider-utils/${result.providerName}/index`); + const providerController = await import(path.join('..', '..', 'provider-utils', result.providerName, 'index')); - if (!providerController) { - context.print.error('Provider not configured for this category'); - return; - } + if (!providerController) { + printer.error('Provider not configured for this category'); + return; + } - const { datasource } = result; - const answers = await providerController.addDatasource(context, category, datasource); - - const { resourceName, databaseName } = answers; - - /** - * Write the new env specific datasource information into - * the team-provider-info file - */ - const currEnv = context.amplify.getEnvInfo().envName; - const teamProviderInfo = stateManager.getTeamProviderInfo(); - - _.set(teamProviderInfo, [currEnv, categories, category, resourceName], { - rdsRegion: answers.region, - rdsClusterIdentifier: answers.dbClusterArn, - rdsSecretStoreArn: answers.secretStoreArn, - rdsDatabaseName: answers.databaseName, - }); - - stateManager.setTeamProviderInfo(undefined, teamProviderInfo); - - const backendConfig = stateManager.getBackendConfig(); - - backendConfig[category][resourceName]['rdsInit'] = true; - - stateManager.setBackendConfig(undefined, backendConfig); - - /** - * Load the MySqlRelationalDBReader - */ - const dbReader = new AuroraServerlessMySQLDatabaseReader( - answers.region, - answers.secretStoreArn, - answers.dbClusterArn, - answers.databaseName, - AWS, - ); - - /** - * Instantiate a new Relational Schema Transformer and perform - * the db instrospection to get the GraphQL Schema and Template Context - */ - const improvePluralizationFlag = FeatureFlags.getBoolean('graphqltransformer.improvePluralization'); - const relationalSchemaTransformer = new RelationalDBSchemaTransformer(dbReader, answers.databaseName, improvePluralizationFlag); - const graphqlSchemaContext = await relationalSchemaTransformer.introspectDatabaseSchema(); - - if (graphqlSchemaContext === null) { - context.print.warning('No importable tables were found in the selected Database.'); - context.print.info(''); - return; - } + const { datasource } = result; + const answers = await providerController.addDatasource(context, category, datasource); + + const { resourceName, databaseName } = answers; - /** - * Merge the GraphQL Schema with the existing schema.graphql in the projects stack - * - */ - const apiDirPath = path.join(pathManager.getBackendDirPath(), category, resourceName); + /** + * Write the new env specific datasource information into + * the team-provider-info file + */ + const currEnv = context.amplify.getEnvInfo().envName; + const teamProviderInfo = stateManager.getTeamProviderInfo(); - fs.ensureDirSync(apiDirPath); + _.set(teamProviderInfo, [currEnv, categories, category, resourceName], { + rdsRegion: answers.region, + rdsClusterIdentifier: answers.dbClusterArn, + rdsSecretStoreArn: answers.secretStoreArn, + rdsDatabaseName: answers.databaseName, + }); - const graphqlSchemaFilePath = path.join(apiDirPath, 'schema.graphql'); - const rdsGraphQLSchemaDoc = graphqlSchemaContext.schemaDoc; - const schemaDirectoryPath = path.join(apiDirPath, 'schema'); + stateManager.setTeamProviderInfo(undefined, teamProviderInfo); + + const backendConfig = stateManager.getBackendConfig(); + + backendConfig[category][resourceName]['rdsInit'] = true; + + stateManager.setBackendConfig(undefined, backendConfig); + + /** + * Load the MySqlRelationalDBReader + */ + const dbReader = new AuroraServerlessMySQLDatabaseReader( + answers.region, + answers.secretStoreArn, + answers.dbClusterArn, + answers.databaseName, + AWS, + ); + + /** + * Instantiate a new Relational Schema Transformer and perform + * the db instrospection to get the GraphQL Schema and Template Context + */ + const improvePluralizationFlag = FeatureFlags.getBoolean('graphqltransformer.improvePluralization'); + const relationalSchemaTransformer = new RelationalDBSchemaTransformer(dbReader, answers.databaseName, improvePluralizationFlag); + const graphqlSchemaContext = await relationalSchemaTransformer.introspectDatabaseSchema(); + + if (graphqlSchemaContext === null) { + printer.warn('No importable tables were found in the selected Database.'); + printer.info(''); + return; + } - if (fs.existsSync(graphqlSchemaFilePath)) { - const typesToBeMerged = [rdsGraphQLSchemaDoc]; - const currGraphQLSchemaDoc = readSchema(graphqlSchemaFilePath); + /** + * Merge the GraphQL Schema with the existing schema.graphql in the projects stack + * + */ + const apiDirPath = pathManager.getResourceDirectoryPath(undefined, category, resourceName); - if (currGraphQLSchemaDoc) { - typesToBeMerged.unshift(currGraphQLSchemaDoc); - } else { - context.print.warning(`Graphql Schema file "${graphqlSchemaFilePath}" is empty.`); - context.print.info(''); - } + fs.ensureDirSync(apiDirPath); - const concatGraphQLSchemaDoc = mergeTypeDefs(typesToBeMerged); + const graphqlSchemaFilePath = path.join(apiDirPath, 'schema.graphql'); + const rdsGraphQLSchemaDoc = graphqlSchemaContext.schemaDoc; + const schemaDirectoryPath = path.join(apiDirPath, 'schema'); - fs.writeFileSync(graphqlSchemaFilePath, graphql.print(concatGraphQLSchemaDoc), 'utf8'); - } else if (fs.existsSync(schemaDirectoryPath)) { - const rdsSchemaFilePath = path.join(schemaDirectoryPath, 'rds.graphql'); + if (fs.existsSync(graphqlSchemaFilePath)) { + const typesToBeMerged = [rdsGraphQLSchemaDoc]; + const currGraphQLSchemaDoc = readSchema(graphqlSchemaFilePath); - fs.writeFileSync(rdsSchemaFilePath, graphql.print(rdsGraphQLSchemaDoc), 'utf8'); + if (currGraphQLSchemaDoc) { + typesToBeMerged.unshift(currGraphQLSchemaDoc); } else { - throw new Error(`Could not find a schema in either ${graphqlSchemaFilePath} or schema directory at ${schemaDirectoryPath}`); + printer.warn(`Graphql Schema file "${graphqlSchemaFilePath}" is empty.`); + printer.info(''); } - const resolversDir = path.join(apiDirPath, 'resolvers'); + const concatGraphQLSchemaDoc = mergeTypeDefs(typesToBeMerged); - /** - * Instantiate a new Relational Template Generator and create - * the template and relational resolvers - */ + fs.writeFileSync(graphqlSchemaFilePath, graphql.print(concatGraphQLSchemaDoc), 'utf8'); + } else if (fs.existsSync(schemaDirectoryPath)) { + const rdsSchemaFilePath = path.join(schemaDirectoryPath, 'rds.graphql'); - const templateGenerator = new RelationalDBTemplateGenerator(graphqlSchemaContext); + fs.writeFileSync(rdsSchemaFilePath, graphql.print(rdsGraphQLSchemaDoc), 'utf8'); + } else { + throw new Error(`Could not find a schema in either ${graphqlSchemaFilePath} or schema directory at ${schemaDirectoryPath}`); + } - let template = templateGenerator.createTemplate(context); + const resolversDir = path.join(apiDirPath, 'resolvers'); - template = templateGenerator.addRelationalResolvers(template, resolversDir, improvePluralizationFlag); + /** + * Instantiate a new Relational Template Generator and create + * the template and relational resolvers + */ - const cfn = templateGenerator.printCloudformationTemplate(template); + const templateGenerator = new RelationalDBTemplateGenerator(graphqlSchemaContext); - /** - * Add the generated the CFN to the appropriate nested stacks directory - */ + let template = templateGenerator.createTemplate(context); - const stacksDir = path.join(apiDirPath, 'stacks'); - const writeToPath = path.join(stacksDir, `${resourceName}-${databaseName}-rds.json`); + template = templateGenerator.addRelationalResolvers(template, resolversDir, improvePluralizationFlag); - fs.writeFileSync(writeToPath, cfn, 'utf8'); + const cfn = templateGenerator.printCloudformationTemplate(template); - context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { forceCompile: true }); + /** + * Add the generated the CFN to the appropriate nested stacks directory + */ - context.print.success(`Successfully added the ${datasource} datasource locally`); - context.print.info(''); - context.print.success('Some next steps:'); - context.print.info('"amplify push" will build all your local backend resources and provision it in the cloud'); - context.print.info( - '"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud', - ); - context.print.info(''); - } catch (error) { - context.print.info(error.stack); - context.print.error('There was an error adding the datasource'); + const stacksDir = path.join(apiDirPath, 'stacks'); + const writeToPath = path.join(stacksDir, `${resourceName}-${databaseName}-rds.json`); - await context.usageData.emitError(error); + fs.writeFileSync(writeToPath, cfn, 'utf8'); - process.exitCode = 1; - } - }, - readSchema, + context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { forceCompile: true }); + + printer.success(`Successfully added the ${datasource} datasource locally`); + printer.blankLine(); + printer.success('Some next steps:'); + printer.info('"amplify push" will build all your local backend resources and provision it in the cloud'); + printer.info( + '"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud', + ); + printer.blankLine(); + } catch (error) { + printer.info(error.stack); + printer.error('There was an error adding the datasource'); + + await context.usageData.emitError(error); + + process.exitCode = 1; + } }; -async function datasourceSelectionPrompt(context, supportedDatasources) { +async function datasourceSelectionPrompt(context: $TSContext, supportedDatasources) { const options = []; Object.keys(supportedDatasources).forEach(datasource => { const optionName = @@ -184,7 +182,7 @@ async function datasourceSelectionPrompt(context, supportedDatasources) { if (options.length === 0) { const errMessage = `No datasources defined by configured providers for category: ${category}`; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); @@ -193,7 +191,7 @@ async function datasourceSelectionPrompt(context, supportedDatasources) { if (options.length === 1) { // No need to ask questions - context.print.info(`Using datasource: ${options[0].value.datasource}, provided by: ${options[0].value.providerName}`); + printer.info(`Using datasource: ${options[0].value.datasource}, provided by: ${options[0].value.providerName}`); return new Promise(resolve => { resolve(options[0].value); @@ -219,7 +217,7 @@ async function getAwsClient(context: $TSContext, action: string) { return await provider.getConfiguredAWSClient(context, 'aurora-serverless', action); } -function readSchema(graphqlSchemaFilePath) { +export function readSchema(graphqlSchemaFilePath: string) { const graphqlSchemaRaw = fs.readFileSync(graphqlSchemaFilePath).toString(); if (graphqlSchemaRaw.trim().length === 0) { diff --git a/packages/amplify-category-api/src/commands/api/add.js b/packages/amplify-category-api/src/commands/api/add.js deleted file mode 100644 index d883208b457..00000000000 --- a/packages/amplify-category-api/src/commands/api/add.js +++ /dev/null @@ -1,75 +0,0 @@ -const inquirer = require('inquirer'); -const subcommand = 'add'; -const category = 'api'; -const apiGatewayService = 'API Gateway'; - -let options; - -module.exports = { - name: subcommand, - run: async context => { - const { amplify } = context; - const servicesMetadata = require('../../provider-utils/supported-services').supportedServices; - return amplify - .serviceSelectionPrompt(context, category, servicesMetadata) - .then(async result => { - options = { - service: result.service, - providerPlugin: result.providerName, - }; - const providerController = require(`../../provider-utils/${result.providerName}/index`); - if (!providerController) { - context.print.error('Provider not configured for this category'); - return; - } - - if ((await shouldUpdateExistingRestApi(context, result.service)) === true) { - return providerController.updateResource(context, category, result.service, { allowContainers: false }); - } - - return providerController.addResource(context, category, result.service, options); - }) - .then(resourceName => { - const { print } = context; - print.success(`Successfully added resource ${resourceName} locally`); - print.info(''); - print.success('Some next steps:'); - print.info('"amplify push" will build all your local backend resources and provision it in the cloud'); - print.info( - '"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud', - ); - print.info(''); - }) - .catch(err => { - context.print.info(err.stack); - context.print.error('There was an error adding the API resource'); - context.usageData.emitError(err); - process.exitCode = 1; - }); - }, -}; - -async function shouldUpdateExistingRestApi(context, selectedService) { - if (selectedService !== apiGatewayService) { - return false; - } - - const { allResources } = await context.amplify.getResourceStatus(); - const hasRestApis = allResources.some(resource => resource.service === apiGatewayService && resource.mobileHubMigrated !== true); - - if (!hasRestApis) { - return false; - } - - const question = [ - { - name: 'update', - message: 'Would you like to add a new path to an existing REST API:', - type: 'confirm', - default: true, - }, - ]; - const answer = await inquirer.prompt(question); - - return answer.update; -} diff --git a/packages/amplify-category-api/src/commands/api/add.ts b/packages/amplify-category-api/src/commands/api/add.ts new file mode 100644 index 00000000000..bf2f64e8c25 --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/add.ts @@ -0,0 +1,64 @@ +import { $TSContext, $TSObject, AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; +import { printer, prompter } from 'amplify-prompts'; +import * as path from 'path'; + +const subcommand = 'add'; +const category = AmplifyCategories.API; + +export const name = subcommand; + +export const run = async (context: $TSContext) => { + const servicesMetadata = (await import(path.join('..', '..', 'provider-utils', 'supported-services'))).supportedServices; + return context.amplify + .serviceSelectionPrompt(context, category, servicesMetadata) + .then(async result => { + const options = { + service: result.service, + providerPlugin: result.providerName, + }; + const providerController = await import(path.join('..', '..', 'provider-utils', result.providerName, 'index')); + if (!providerController) { + printer.error('Provider not configured for this category'); + return; + } + + if ((await shouldUpdateExistingRestApi(context, result.service)) === true) { + return providerController.updateResource(context, category, result.service, { allowContainers: false }); + } + + return providerController.addResource(context, result.service, options); + }) + .then((resourceName: string) => { + printer.success(`Successfully added resource ${resourceName} locally`); + printer.blankLine(); + printer.success('Some next steps:'); + printer.info('"amplify push" will build all your local backend resources and provision it in the cloud'); + printer.info( + '"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud', + ); + printer.blankLine(); + }) + .catch(async err => { + printer.info(err.stack); + printer.error('There was an error adding the API resource'); + await context.usageData.emitError(err); + process.exitCode = 1; + }); +}; + +async function shouldUpdateExistingRestApi(context: $TSContext, selectedService: string): Promise { + if (selectedService !== AmplifySupportedService.APIGW) { + return false; + } + + const { allResources } = await context.amplify.getResourceStatus(); + const hasRestApis = allResources.some( + (resource: $TSObject) => resource.service === AmplifySupportedService.APIGW && resource.mobileHubMigrated !== true, + ); + + if (!hasRestApis) { + return false; + } + + return prompter.confirmContinue('Would you like to add a new path to an existing REST API:'); +} diff --git a/packages/amplify-category-api/src/commands/api/console.js b/packages/amplify-category-api/src/commands/api/console.js deleted file mode 100644 index a8bc4d57bc4..00000000000 --- a/packages/amplify-category-api/src/commands/api/console.js +++ /dev/null @@ -1,25 +0,0 @@ -const subcommand = 'console'; -const category = 'api'; - -module.exports = { - name: subcommand, - run: async context => { - const { amplify } = context; - const servicesMetadata = require('../../provider-utils/supported-services').supportedServices; - return amplify - .serviceSelectionPrompt(context, category, servicesMetadata) - .then(async result => { - const providerController = require(`../../provider-utils/${result.providerName}/index`); - if (!providerController) { - throw new Error(`Provider "${result.providerName}" is not configured for this category`); - } - return await providerController.console(context, result.service); - }) - .catch(err => { - context.print.error('Error opening console.'); - context.print.info(err.message); - context.usageData.emitError(err); - process.exitCode = 1; - }); - }, -}; diff --git a/packages/amplify-category-api/src/commands/api/console.ts b/packages/amplify-category-api/src/commands/api/console.ts new file mode 100644 index 00000000000..2e2430047b0 --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/console.ts @@ -0,0 +1,24 @@ +import { $TSContext, AmplifyCategories } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as path from 'path'; + +const subcommand = 'console'; + +export const name = subcommand; + +export const run = async (context: $TSContext) => { + const servicesMetadata = (await import(path.join('..', '..', 'provider-utils', 'supported-services'))).supportedServices; + const result = await context.amplify.serviceSelectionPrompt(context, AmplifyCategories.API, servicesMetadata); + try { + const providerController = await import(path.join('..', '..', 'provider-utils', result.providerName, 'index')); + if (!providerController) { + throw new Error(`Provider "${result.providerName}" is not configured for this category`); + } + return providerController.console(context, result.service); + } catch (err) { + printer.error('Error opening console.'); + printer.info(err.message); + await context.usageData.emitError(err); + process.exitCode = 1; + } +}; diff --git a/packages/amplify-category-api/src/commands/api/gql-compile.js b/packages/amplify-category-api/src/commands/api/gql-compile.js deleted file mode 100644 index a53e64d0feb..00000000000 --- a/packages/amplify-category-api/src/commands/api/gql-compile.js +++ /dev/null @@ -1,20 +0,0 @@ -const subcommand = 'gql-compile'; - -module.exports = { - name: subcommand, - run: async context => { - try { - const { - parameters: { options }, - } = context; - await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { - forceCompile: true, - minify: options['minify'], - }); - } catch (err) { - context.print.error(err.toString()); - context.usageData.emitError(err); - process.exitCode = 1; - } - }, -}; diff --git a/packages/amplify-category-api/src/commands/api/gql-compile.ts b/packages/amplify-category-api/src/commands/api/gql-compile.ts new file mode 100644 index 00000000000..bcbbfba4f83 --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/gql-compile.ts @@ -0,0 +1,22 @@ +import { $TSContext } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; + +const subcommand = 'gql-compile'; + +export const name = subcommand; + +export const run = async (context: $TSContext) => { + try { + const { + parameters: { options }, + } = context; + await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { + forceCompile: true, + minify: options['minify'], + }); + } catch (err) { + printer.error(err.toString()); + await context.usageData.emitError(err); + process.exitCode = 1; + } +}; diff --git a/packages/amplify-category-api/src/commands/api/override.ts b/packages/amplify-category-api/src/commands/api/override.ts new file mode 100644 index 00000000000..a7d10476369 --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/override.ts @@ -0,0 +1,70 @@ +import { + $TSContext, + AmplifyCategories, + AmplifySupportedService, + generateOverrideSkeleton, + pathManager, + stateManager, +} from 'amplify-cli-core'; +import { printer, prompter } from 'amplify-prompts'; +import * as path from 'path'; +import { ApigwInputState } from '../../provider-utils/awscloudformation/apigw-input-state'; +import { ApigwStackTransform } from '../../provider-utils/awscloudformation/cdk-stack-builder'; + +export const name = 'override'; + +export const run = async (context: $TSContext) => { + const amplifyMeta = stateManager.getMeta(); + const apiResources: string[] = []; + + if (amplifyMeta[AmplifyCategories.API]) { + Object.keys(amplifyMeta[AmplifyCategories.API]).forEach(resourceName => { + apiResources.push(resourceName); + }); + } + + if (apiResources.length === 0) { + const errMessage = 'No resources to override. You need to add a resource.'; + printer.error(errMessage); + return; + } + + let selectedResourceName: string = apiResources[0]; + + if (apiResources.length > 1) { + selectedResourceName = await prompter.pick('Which resource would you like to add overrides for?', apiResources); + } + + const { service }: { service: string } = amplifyMeta[AmplifyCategories.API][selectedResourceName]; + const destPath = pathManager.getResourceDirectoryPath(undefined, AmplifyCategories.API, selectedResourceName); + + const srcPath = path.join( + __dirname, + '..', + '..', + '..', + 'resources', + 'awscloudformation', + 'overrides-resource', + service === AmplifySupportedService.APIGW ? 'APIGW' : service, // avoid space in filename + ); + + // Make sure to migrate first + if (service === AmplifySupportedService.APPSYNC) { + throw 'To be implemented'; + } else if (service === AmplifySupportedService.APIGW) { + // Migration logic goes in here + const apigwInputState = ApigwInputState.getInstance(context, selectedResourceName); + if (!apigwInputState.cliInputsFileExists()) { + if (await prompter.yesOrNo('File migration required to continue. Do you want to continue?', true)) { + await apigwInputState.migrateApigwResource(selectedResourceName); + const stackGenerator = new ApigwStackTransform(context, selectedResourceName); + stackGenerator.transform(); + } else { + return; + } + } + } + + await generateOverrideSkeleton(context, srcPath, destPath); +}; diff --git a/packages/amplify-category-api/src/commands/api/push.js b/packages/amplify-category-api/src/commands/api/push.js deleted file mode 100644 index 8a0da0bb5a6..00000000000 --- a/packages/amplify-category-api/src/commands/api/push.js +++ /dev/null @@ -1,17 +0,0 @@ -const subcommand = 'push'; -const category = 'api'; - -module.exports = { - name: subcommand, - run: async context => { - const { amplify, parameters } = context; - const resourceName = parameters.first; - context.amplify.constructExeInfo(context); - return amplify.pushResources(context, category, resourceName).catch(err => { - context.print.error('There was an error pushing the API resource'); - context.print.error(err.toString()); - context.usageData.emitError(err); - process.exitCode = 1; - }); - }, -}; diff --git a/packages/amplify-category-api/src/commands/api/push.ts b/packages/amplify-category-api/src/commands/api/push.ts new file mode 100644 index 00000000000..ce32110c1cf --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/push.ts @@ -0,0 +1,17 @@ +import { $TSContext, AmplifyCategories } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; + +const subcommand = 'push'; + +export const name = subcommand; + +export const run = async (context: $TSContext) => { + const resourceName = context.parameters.first; + context.amplify.constructExeInfo(context); + return context.amplify.pushResources(context, AmplifyCategories.API, resourceName).catch(async err => { + printer.error('There was an error pushing the API resource'); + printer.error(err.toString()); + await context.usageData.emitError(err); + process.exitCode = 1; + }); +}; diff --git a/packages/amplify-category-api/src/commands/api/rebuild.ts b/packages/amplify-category-api/src/commands/api/rebuild.ts index 26b8ead3641..55c269da95a 100644 --- a/packages/amplify-category-api/src/commands/api/rebuild.ts +++ b/packages/amplify-category-api/src/commands/api/rebuild.ts @@ -1,8 +1,7 @@ -import { $TSAny, $TSContext, FeatureFlags, stateManager } from 'amplify-cli-core'; +import { $TSAny, $TSContext, AmplifyCategories, FeatureFlags, stateManager } from 'amplify-cli-core'; import { printer, prompter, exact } from 'amplify-prompts'; const subcommand = 'rebuild'; -const category = 'api'; export const name = subcommand; @@ -36,5 +35,5 @@ export const run = async (context: $TSContext) => { const { amplify, parameters } = context; const resourceName = parameters.first; amplify.constructExeInfo(context); - return amplify.pushResources(context, category, resourceName, undefined, rebuild); + return amplify.pushResources(context, AmplifyCategories.API, resourceName, undefined, rebuild); }; diff --git a/packages/amplify-category-api/src/commands/api/remove.js b/packages/amplify-category-api/src/commands/api/remove.js deleted file mode 100644 index 8c334611a5d..00000000000 --- a/packages/amplify-category-api/src/commands/api/remove.js +++ /dev/null @@ -1,31 +0,0 @@ -const path = require('path'); - -const subcommand = 'remove'; -const category = 'api'; -const gqlConfigFilename = '.graphqlconfig.yml'; - -module.exports = { - name: subcommand, - run: async context => { - const { amplify, parameters } = context; - const resourceName = parameters.first; - - return amplify - .removeResource(context, category, resourceName) - .then(resourceValues => { - if (!resourceValues) return; // indicates that the customer selected "no" at the confirmation prompt - if (resourceValues.service === 'AppSync') { - const { projectPath } = amplify.getEnvInfo(); - - const gqlConfigFile = path.normalize(path.join(projectPath, gqlConfigFilename)); - context.filesystem.remove(gqlConfigFile); - } - }) - .catch(err => { - context.print.info(err.stack); - context.print.error('There was an error removing the api resource'); - context.usageData.emitError(err); - process.exitCode = 1; - }); - }, -}; diff --git a/packages/amplify-category-api/src/commands/api/remove.ts b/packages/amplify-category-api/src/commands/api/remove.ts new file mode 100644 index 00000000000..ac67429d87c --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/remove.ts @@ -0,0 +1,32 @@ +import { $TSContext, AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as path from 'path'; + +const subcommand = 'remove'; +const gqlConfigFilename = '.graphqlconfig.yml'; + +export const name = subcommand; + +export const run = async (context: $TSContext) => { + const resourceName = context.parameters.first; + + const resourceValues = await context.amplify.removeResource(context, AmplifyCategories.API, resourceName, { + serviceSuffix: { [AmplifySupportedService.APPSYNC]: '(GraphQL API)', [AmplifySupportedService.APIGW]: '(REST API)' }, + }); + try { + if (!resourceValues) { + return; + } // indicates that the customer selected "no" at the confirmation prompt + if (resourceValues.service === AmplifySupportedService.APPSYNC) { + const { projectPath } = context.amplify.getEnvInfo(); + + const gqlConfigFile = path.normalize(path.join(projectPath, gqlConfigFilename)); + context.filesystem.remove(gqlConfigFile); + } + } catch (err) { + printer.info(err.stack); + printer.error('There was an error removing the api resource'); + await context.usageData.emitError(err); + process.exitCode = 1; + } +}; diff --git a/packages/amplify-category-api/src/commands/api/update.js b/packages/amplify-category-api/src/commands/api/update.js deleted file mode 100644 index 03dbb328f12..00000000000 --- a/packages/amplify-category-api/src/commands/api/update.js +++ /dev/null @@ -1,29 +0,0 @@ -const subcommand = 'update'; -const category = 'api'; - -module.exports = { - name: subcommand, - alias: ['configure'], - run: async context => { - const { amplify } = context; - const servicesMetadata = require('../../provider-utils/supported-services').supportedServices; - - return amplify - .serviceSelectionPrompt(context, category, servicesMetadata) - .then(result => { - const providerController = require(`../../provider-utils/${result.providerName}/index`); - if (!providerController) { - context.print.error('Provider not configured for this category'); - return; - } - return providerController.updateResource(context, category, result.service); - }) - .then(() => context.print.success('Successfully updated resource')) - .catch(err => { - context.print.error(err.message); - console.log(err.stack); - context.usageData.emitError(err); - process.exitCode = 1; - }); - }, -}; diff --git a/packages/amplify-category-api/src/commands/api/update.ts b/packages/amplify-category-api/src/commands/api/update.ts new file mode 100644 index 00000000000..8813a045bef --- /dev/null +++ b/packages/amplify-category-api/src/commands/api/update.ts @@ -0,0 +1,30 @@ +import { $TSContext, AmplifyCategories } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as path from 'path'; + +const subcommand = 'update'; + +export const name = subcommand; +export const alias = ['configure']; + +export const run = async (context: $TSContext) => { + const servicesMetadata = (await import(path.join('..', '..', 'provider-utils', 'supported-services'))).supportedServices; + + return context.amplify + .serviceSelectionPrompt(context, AmplifyCategories.API, servicesMetadata) + .then(async result => { + const providerController = await import(path.join('..', '..', 'provider-utils', result.providerName, 'index')); + if (!providerController) { + printer.error('Provider not configured for this category'); + return; + } + return providerController.updateResource(context, AmplifyCategories.API, result.service); + }) + .then(() => printer.success('Successfully updated resource')) + .catch(async err => { + printer.error(err.message); + printer.info(err.stack); + await context.usageData.emitError(err); + process.exitCode = 1; + }); +}; diff --git a/packages/amplify-category-api/src/index.ts b/packages/amplify-category-api/src/index.ts index 4ada1ab74d5..f5561484768 100644 --- a/packages/amplify-category-api/src/index.ts +++ b/packages/amplify-category-api/src/index.ts @@ -1,42 +1,57 @@ +import { + $TSContext, + $TSObject, + AmplifyCategories, + AmplifySupportedService, + buildOverrideDir, + pathManager, + stateManager, +} from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; import { validateAddApiRequest, validateUpdateApiRequest } from 'amplify-util-headless-input'; -import fs from 'fs-extra'; -import path from 'path'; +import * as fs from 'fs-extra'; +import * as path from 'path'; import { run } from './commands/api/console'; +import { getAppSyncAuthConfig, getAppSyncResourceName } from './provider-utils/awscloudformation//utils/amplify-meta-utils'; +import { provider } from './provider-utils/awscloudformation/aws-constants'; +import { ApigwStackTransform } from './provider-utils/awscloudformation/cdk-stack-builder'; import { getCfnApiArtifactHandler } from './provider-utils/awscloudformation/cfn-api-artifact-handler'; import { askAuthQuestions } from './provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough'; -import { getAppSyncResourceName, getAppSyncAuthConfig } from './provider-utils/awscloudformation//utils/amplify-meta-utils'; import { authConfigToAppSyncAuthType } from './provider-utils/awscloudformation/utils/auth-config-to-app-sync-auth-type-bi-di-mapper'; export { NETWORK_STACK_LOGICAL_ID } from './category-constants'; +export { addAdminQueriesApi, updateAdminQueriesApi } from './provider-utils/awscloudformation/'; export { DEPLOYMENT_MECHANISM } from './provider-utils/awscloudformation/base-api-stack'; -export { EcsStack } from './provider-utils/awscloudformation/ecs-apigw-stack'; +export { getContainers } from './provider-utils/awscloudformation/docker-compose'; export { EcsAlbStack } from './provider-utils/awscloudformation/ecs-alb-stack'; -export { getGitHubOwnerRepoFromPath } from './provider-utils/awscloudformation/utils/github'; +export { EcsStack } from './provider-utils/awscloudformation/ecs-apigw-stack'; +export { promptToAddApiKey } from './provider-utils/awscloudformation/prompt-to-add-api-key'; export { - generateContainersArtifacts, ApiResource, + generateContainersArtifacts, processDockerConfig, } from './provider-utils/awscloudformation/utils/containers-artifacts'; -export { getContainers } from './provider-utils/awscloudformation/docker-compose'; -export { promptToAddApiKey } from './provider-utils/awscloudformation/prompt-to-add-api-key'; - -const category = 'api'; +export { getGitHubOwnerRepoFromPath } from './provider-utils/awscloudformation/utils/github'; +const category = AmplifyCategories.API; const categories = 'categories'; -export async function console(context) { +export async function console(context: $TSContext) { await run(context); } -export async function migrate(context, serviceName) { - const { projectPath, amplifyMeta } = context.migrationInfo; +export async function migrate(context: $TSContext, serviceName?: string) { + const { projectPath } = context.migrationInfo; + const amplifyMeta = stateManager.getMeta(); const migrateResourcePromises = []; - Object.keys(amplifyMeta).forEach(categoryName => { + for (const categoryName of Object.keys(amplifyMeta)) { if (categoryName === category) { - Object.keys(amplifyMeta[category]).forEach(resourceName => { + for (const resourceName of Object.keys(amplifyMeta[category])) { try { if (amplifyMeta[category][resourceName].providerPlugin) { - const providerController = require(`./provider-utils/${amplifyMeta[category][resourceName].providerPlugin}/index`); + const providerController = await import( + path.join('.', 'provider-utils', amplifyMeta[category][resourceName].providerPlugin, 'index') + ); if (providerController) { if (!serviceName || serviceName === amplifyMeta[category][resourceName].service) { migrateResourcePromises.push( @@ -45,20 +60,20 @@ export async function migrate(context, serviceName) { } } } else { - context.print.error(`Provider not configured for ${category}: ${resourceName}`); + printer.error(`Provider not configured for ${category}: ${resourceName}`); } } catch (e) { - context.print.warning(`Could not run migration for ${category}: ${resourceName}`); + printer.warn(`Could not run migration for ${category}: ${resourceName}`); throw e; } - }); + } } - }); + } await Promise.all(migrateResourcePromises); } -export async function initEnv(context) { +export async function initEnv(context: $TSContext) { const datasource = 'Aurora Serverless'; const service = 'service'; const rdsInit = 'rdsInit'; @@ -73,7 +88,7 @@ export async function initEnv(context) { * Check if we need to do the walkthrough, by looking to see if previous environments have * configured an RDS datasource */ - const backendConfigFilePath = amplify.pathManager.getBackendConfigFilePath(); + const backendConfigFilePath = pathManager.getBackendConfigFilePath(); // If this is a mobile hub migrated project without locally added resources then there is no // backend config exists yet. @@ -81,7 +96,7 @@ export async function initEnv(context) { return; } - const backendConfig = amplify.readJsonFile(backendConfigFilePath); + const backendConfig = stateManager.getBackendConfig(); if (!backendConfig[category]) { return; @@ -89,9 +104,9 @@ export async function initEnv(context) { let resourceName; const apis = Object.keys(backendConfig[category]); - for (let i = 0; i < apis.length; i += 1) { - if (backendConfig[category][apis[i]][service] === 'AppSync') { - resourceName = apis[i]; + for (const api of apis) { + if (backendConfig[category][api][service] === AmplifySupportedService.APPSYNC) { + resourceName = api; break; } } @@ -106,10 +121,10 @@ export async function initEnv(context) { return; } - const providerController = require('./provider-utils/awscloudformation/index'); + const providerController = await import(path.join('.', 'provider-utils', provider, 'index')); if (!providerController) { - context.print.error('Provider not configured for this category'); + printer.error('Provider not configured for this category'); return; } @@ -117,8 +132,7 @@ export async function initEnv(context) { * Check team provider info to ensure it hasn't already been created for current env */ const currEnv = amplify.getEnvInfo().envName; - const teamProviderInfoFilePath = amplify.pathManager.getProviderInfoFilePath(); - const teamProviderInfo = amplify.readJsonFile(teamProviderInfoFilePath); + const teamProviderInfo = stateManager.getTeamProviderInfo(); if ( teamProviderInfo[currEnv][categories] && teamProviderInfo[currEnv][categories][category] && @@ -153,16 +167,15 @@ export async function initEnv(context) { teamProviderInfo[currEnv][categories][category][resourceName][rdsSecretStoreArn] = answers.secretStoreArn; teamProviderInfo[currEnv][categories][category][resourceName][rdsDatabaseName] = answers.databaseName; - fs.writeFileSync(teamProviderInfoFilePath, JSON.stringify(teamProviderInfo, null, 4)); + stateManager.setTeamProviderInfo(undefined, teamProviderInfo); }) .then(() => { context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { forceCompile: true }); }); } -export async function getPermissionPolicies(context, resourceOpsMapping) { - const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); - const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); +export async function getPermissionPolicies(context: $TSContext, resourceOpsMapping: $TSObject) { + const amplifyMeta = stateManager.getMeta(); const permissionPolicies = []; const resourceAttributes = []; @@ -171,7 +184,7 @@ export async function getPermissionPolicies(context, resourceOpsMapping) { try { const providerName = amplifyMeta[category][resourceName].providerPlugin; if (providerName) { - const providerController = require(`./provider-utils/${providerName}/index`); + const providerController = await import(path.join('.', 'provider-utils', providerName, 'index')); const { policy, attributes } = await providerController.getPermissionPolicies( context, amplifyMeta[category][resourceName].service, @@ -181,10 +194,10 @@ export async function getPermissionPolicies(context, resourceOpsMapping) { permissionPolicies.push(policy); resourceAttributes.push({ resourceName, attributes, category }); } else { - context.print.error(`Provider not configured for ${category}: ${resourceName}`); + printer.error(`Provider not configured for ${category}: ${resourceName}`); } } catch (e) { - context.print.warning(`Could not get policies for ${category}: ${resourceName}`); + printer.warn(`Could not get policies for ${category}: ${resourceName}`); throw e; } }), @@ -192,7 +205,7 @@ export async function getPermissionPolicies(context, resourceOpsMapping) { return { permissionPolicies, resourceAttributes }; } -export async function executeAmplifyCommand(context) { +export async function executeAmplifyCommand(context: $TSContext) { let commandPath = path.normalize(path.join(__dirname, 'commands')); if (context.input.command === 'help') { commandPath = path.join(commandPath, category); @@ -200,11 +213,11 @@ export async function executeAmplifyCommand(context) { commandPath = path.join(commandPath, category, context.input.command); } - const commandModule = require(commandPath); + const commandModule = await import(commandPath); await commandModule.run(context); } -export const executeAmplifyHeadlessCommand = async (context, headlessPayload: string) => { +export const executeAmplifyHeadlessCommand = async (context: $TSContext, headlessPayload: string) => { switch (context.input.command) { case 'add': await getCfnApiArtifactHandler(context).createArtifacts(await validateAddApiRequest(headlessPayload)); @@ -213,23 +226,24 @@ export const executeAmplifyHeadlessCommand = async (context, headlessPayload: st await getCfnApiArtifactHandler(context).updateArtifacts(await validateUpdateApiRequest(headlessPayload)); break; default: - context.print.error(`Headless mode for ${context.input.command} api is not implemented yet`); + printer.error(`Headless mode for ${context.input.command} api is not implemented yet`); } }; -export async function handleAmplifyEvent(context, args) { - context.print.info(`${category} handleAmplifyEvent to be implemented`); - context.print.info(`Received event args ${args}`); +export async function handleAmplifyEvent(context: $TSContext, args) { + printer.info(`${category} handleAmplifyEvent to be implemented`); + printer.info(`Received event args ${args}`); } -export async function addGraphQLAuthorizationMode(context, args) { +export async function addGraphQLAuthorizationMode(context: $TSContext, args: $TSObject) { const { authType, printLeadText, authSettings } = args; - const apiName = getAppSyncResourceName(context.amplify.getProjectMeta()); + const meta = stateManager.getMeta(); + const apiName = getAppSyncResourceName(meta); if (!apiName) { return; } - const authConfig = getAppSyncAuthConfig(context.amplify.getProjectMeta()); + const authConfig = getAppSyncAuthConfig(meta); const addAuthConfig = await askAuthQuestions(authType, context, printLeadText, authSettings); authConfig.additionalAuthenticationProviders.push(addAuthConfig); await context.amplify.updateamplifyMetaAfterResourceUpdate(category, apiName, 'output', { authConfig }); @@ -250,3 +264,23 @@ export async function addGraphQLAuthorizationMode(context, args) { return addAuthConfig; } + +export async function transformCategoryStack(context: $TSContext, resource: $TSObject) { + if (resource.service === AmplifySupportedService.APIGW) { + if (canResourceBeTransformed(resource.resourceName)) { + const backendDir = pathManager.getBackendDirPath(); + const overrideDir = pathManager.getResourceDirectoryPath(undefined, AmplifyCategories.API, resource.resourceName); + await buildOverrideDir(backendDir, overrideDir).catch(error => { + printer.debug(`Skipping build as ${error.message}`); + return false; + }); + // Rebuild CFN + const apigwStack = new ApigwStackTransform(context, resource.resourceName); + apigwStack.transform(); + } + } +} + +function canResourceBeTransformed(resourceName: string) { + return stateManager.resourceInputsJsonExists(undefined, AmplifyCategories.API, resourceName); +} diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/apigw-input-state.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/apigw-input-state.ts new file mode 100644 index 00000000000..c155b40cf97 --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/apigw-input-state.ts @@ -0,0 +1,250 @@ +import { + $TSContext, + $TSObject, + AmplifyCategories, + AmplifySupportedService, + CLIInputSchemaValidator, + isResourceNameUnique, + JSONUtilities, + PathConstants, + pathManager, + stateManager, +} from 'amplify-cli-core'; +import { printer, prompter } from 'amplify-prompts'; +import * as fs from 'fs-extra'; +import { join } from 'path'; +import { ApigwInputs, ApigwStackTransform, CrudOperation, Path, PermissionSetting } from './cdk-stack-builder'; +import { ApigwWalkthroughReturnPromise } from './service-walkthrough-types/apigw-types'; + +export class ApigwInputState { + private static instance: ApigwInputState; + projectRootPath: string; + resourceName: string; + paths: { [pathName: string]: Path }; + + private constructor(private readonly context: $TSContext, resourceName?: string) { + this.projectRootPath = pathManager.findProjectRoot(); + this.resourceName = resourceName; + ApigwInputState.instance = this; + } + + public static getInstance(context: $TSContext, resourceName?: string) { + if (!ApigwInputState.instance) { + new ApigwInputState(context, resourceName); + } + return ApigwInputState.instance; + } + + public addAdminQueriesResource = async (adminQueriesProps: AdminQueriesProps) => { + this.resourceName = adminQueriesProps.apiName; + this.paths = { + '/{proxy+}': { + lambdaFunction: adminQueriesProps.functionName, + permissions: { + setting: PermissionSetting.PRIVATE, + auth: [CrudOperation.CREATE, CrudOperation.READ, CrudOperation.UPDATE, CrudOperation.DELETE], + }, + }, + }; + + await this.createApigwArtifacts(); + + // Update amplify-meta and backend-config + const backendConfigs = { + service: AmplifySupportedService.APIGW, + providerPlugin: 'awscloudformation', + authorizationType: 'AMAZON_COGNITO_USER_POOLS', + dependsOn: adminQueriesProps.dependsOn, + }; + + await this.context.amplify.updateamplifyMetaAfterResourceAdd(AmplifyCategories.API, adminQueriesProps.apiName, backendConfigs); + }; + + public updateAdminQueriesResource = async (adminQueriesProps: AdminQueriesProps) => { + this.resourceName = adminQueriesProps.apiName; + this.paths = { + '/{proxy+}': { + lambdaFunction: adminQueriesProps.functionName, + permissions: { + setting: PermissionSetting.PRIVATE, + auth: [CrudOperation.CREATE, CrudOperation.READ, CrudOperation.UPDATE, CrudOperation.DELETE], + }, + }, + }; + + await this.createApigwArtifacts(); + + await this.context.amplify.updateamplifyMetaAfterResourceUpdate( + AmplifyCategories.API, + adminQueriesProps.apiName, + 'dependsOn', + adminQueriesProps.dependsOn, + ); + }; + + public addApigwResource = async (serviceWalkthroughPromise: ApigwWalkthroughReturnPromise, options: $TSObject) => { + const { answers } = await serviceWalkthroughPromise; + + this.resourceName = answers.resourceName; + this.paths = answers.paths; + options.dependsOn = answers.dependsOn; + + isResourceNameUnique(AmplifyCategories.API, this.resourceName); + + await this.createApigwArtifacts(); + + this.context.amplify.updateamplifyMetaAfterResourceAdd(AmplifyCategories.API, this.resourceName, options); + return this.resourceName; + }; + + public updateApigwResource = async (updateWalkthroughPromise: Promise<$TSObject>) => { + const { answers } = await updateWalkthroughPromise; + + this.resourceName = answers.resourceName; + this.paths = answers.paths; + + // this.addPolicyResourceNameToPaths(answers.paths); + await this.createApigwArtifacts(); + + this.context.amplify.updateamplifyMetaAfterResourceUpdate(AmplifyCategories.API, this.resourceName, 'dependsOn', answers.dependsOn); + return this.resourceName; + }; + + public migrateAdminQueries = async (adminQueriesProps: AdminQueriesProps) => { + this.resourceName = this.resourceName ?? adminQueriesProps.apiName; + if (!(await prompter.confirmContinue(`Migration for ${this.resourceName} is required. Continue?`))) { + return; + } + const resourceDirPath = pathManager.getResourceDirectoryPath(this.projectRootPath, AmplifyCategories.API, this.resourceName); + + this.context.filesystem.remove(join(resourceDirPath, PathConstants.ParametersJsonFileName)); + this.context.filesystem.remove(join(resourceDirPath, 'admin-queries-cloudformation-template.json')); + + return this.updateAdminQueriesResource(adminQueriesProps); + }; + + public migrateApigwResource = async (resourceName: string) => { + this.resourceName = this.resourceName ?? resourceName; + if (!(await prompter.confirmContinue(`Migration for ${this.resourceName} is required. Continue?`))) { + return; + } + const deprecatedParametersFileName = 'api-params.json'; + console.log('DEBUG', this.projectRootPath, AmplifyCategories.API, this.resourceName); + const resourceDirPath = pathManager.getResourceDirectoryPath(this.projectRootPath, AmplifyCategories.API, this.resourceName); + const deprecatedParametersFilePath = join(resourceDirPath, deprecatedParametersFileName); + let deprecatedParameters: $TSObject; + try { + deprecatedParameters = JSONUtilities.readJson<$TSObject>(deprecatedParametersFilePath); + } catch (e) { + printer.error(`Error reading ${deprecatedParametersFileName} file for ${this.resourceName} resource`); + throw e; + } + + this.paths = {}; + + function convertDeprecatedPermissionToCRUD(deprecatedPrivacy: string) { + let privacyList: string[]; + if (deprecatedPrivacy === 'r') { + privacyList = [CrudOperation.READ]; + } else if (deprecatedPrivacy === 'rw') { + privacyList = [CrudOperation.CREATE, CrudOperation.READ, CrudOperation.UPDATE, CrudOperation.DELETE]; + } + return privacyList; + } + + deprecatedParameters.paths.forEach((path: $TSObject) => { + let pathPermissionSetting = + path.privacy.open === true + ? PermissionSetting.OPEN + : path.privacy.private === true + ? PermissionSetting.PRIVATE + : PermissionSetting.PROTECTED; + + let auth; + let guest; + // convert deprecated permissions to CRUD structure + if (path.privacy.auth && ['r', 'rw'].includes(path.privacy.auth)) { + auth = convertDeprecatedPermissionToCRUD(path.privacy.auth); + } + if (path.privacy.unauth && ['r', 'rw'].includes(path.privacy.unauth)) { + auth = convertDeprecatedPermissionToCRUD(path.privacy.unauth); + } + + this.paths[path.name] = { + permissions: { + setting: pathPermissionSetting, + auth, + guest, + }, + lambdaFunction: path.lambdaFunction, + }; + }); + + await this.createApigwArtifacts(); + + this.context.filesystem.remove(deprecatedParametersFilePath); + this.context.filesystem.remove(join(resourceDirPath, PathConstants.ParametersJsonFileName)); + this.context.filesystem.remove(join(resourceDirPath, PathConstants.CfnFileName(this.resourceName))); + }; + + public cliInputsFileExists() { + return stateManager.resourceInputsJsonExists(this.projectRootPath, AmplifyCategories.API, this.resourceName); + } + + public getCliInputPayload() { + return stateManager.getResourceInputsJson(this.projectRootPath, AmplifyCategories.API, this.resourceName); + } + + public isCLIInputsValid(cliInputs?: ApigwInputs) { + if (!cliInputs) { + cliInputs = this.getCliInputPayload(); + } + + const schemaValidator = new CLIInputSchemaValidator(AmplifySupportedService.APIGW, AmplifyCategories.API, 'APIGatewayCLIInputs'); + schemaValidator.validateInput(JSONUtilities.stringify(cliInputs)); + } + + private async createApigwArtifacts() { + const resourceDirPath = pathManager.getResourceDirectoryPath(this.projectRootPath, AmplifyCategories.API, this.resourceName); + fs.ensureDirSync(resourceDirPath); + + const buildDirPath = join(resourceDirPath, PathConstants.BuildDirName); + fs.ensureDirSync(buildDirPath); + + stateManager.setResourceInputsJson(this.projectRootPath, AmplifyCategories.API, this.resourceName, { version: 1, paths: this.paths }); + + stateManager.setResourceParametersJson(this.projectRootPath, AmplifyCategories.API, this.resourceName, {}); + + const stack = new ApigwStackTransform(this.context, this.resourceName); + await stack.transform(); + } + + convertCrudOperationsToPermissions(crudOps: CrudOperation[]) { + const output = []; + for (const op of crudOps) { + switch (op) { + case CrudOperation.CREATE: + output.push('/POST'); + break; + case CrudOperation.READ: + output.push('/GET'); + break; + case CrudOperation.UPDATE: + output.push('/PUT'); + output.push('/PATCH'); + break; + case CrudOperation.DELETE: + output.push('/DELETE'); + break; + } + } + return output; + } +} + +type AdminQueriesProps = { + apiName: string; + functionName: string; + authResourceName: string; + dependsOn: $TSObject[]; +}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/aws-constants.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/aws-constants.ts index 824a2cafef6..418b53b47ff 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/aws-constants.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/aws-constants.ts @@ -1,8 +1,8 @@ -import path from 'path'; +import * as path from 'path'; export const provider = 'awscloudformation'; export const parametersFileName = 'api-params.json'; export const cfnParametersFilename = 'parameters.json'; export const gqlSchemaFilename = 'schema.graphql'; -export const rootAssetDir = path.resolve(path.join(__dirname, '../../../resources/awscloudformation')); +export const rootAssetDir = path.resolve(path.join(__dirname, '..', '..', '..', 'resources', 'awscloudformation')); diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-builder.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-builder.ts new file mode 100644 index 00000000000..2d42d8cb4b2 --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-builder.ts @@ -0,0 +1,402 @@ +import * as apigw from '@aws-cdk/aws-apigateway'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as cdk from '@aws-cdk/core'; +import { $TSObject, JSONUtilities } from 'amplify-cli-core'; +import { AmplifyApigwResourceTemplate, ApigwInputs, ApigwPathPolicy } from './types'; + +const CFN_TEMPLATE_FORMAT_VERSION = '2010-09-09'; +const ROOT_CFN_DESCRIPTION = 'API Gateway Resource for AWS Amplify CLI'; + +export class AmplifyApigwResourceStack extends cdk.Stack implements AmplifyApigwResourceTemplate { + _scope: cdk.Construct; + restApi!: apigw.CfnRestApi; + deploymentResource: apigw.CfnDeployment; + _lambdaPermission: lambda.CfnPermission; + _props: ApigwInputs; + paths: $TSObject; + policies: { [pathName: string]: ApigwPathPolicy }; + _cfnParameterMap: Map = new Map(); + + constructor(scope: cdk.Construct, id: string, props: ApigwInputs) { + super(scope, id, undefined); + this._scope = scope; + this._props = props; + this.paths = {}; + this.templateOptions.templateFormatVersion = CFN_TEMPLATE_FORMAT_VERSION; + this.templateOptions.description = ROOT_CFN_DESCRIPTION; + } + + /** + * + * @param props + * @param logicalId + */ + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void { + new cdk.CfnOutput(this, logicalId, props); + } + + /** + * + * @param props + * @param logicalId + */ + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void { + new cdk.CfnMapping(this, logicalId, props); + } + + /** + * + * @param props + * @param logicalId + */ + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void { + new cdk.CfnCondition(this, logicalId, props); + } + + /** + * + * @param props + * @param logicalId + */ + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void { + new cdk.CfnResource(this, logicalId, props); + } + + /** + * + * @param props + * @param logicalId + */ + addLambdaPermissionCfnResource(props: lambda.CfnPermissionProps, logicalId: string): void { + new lambda.CfnPermission(this, logicalId, props); + } + + /** + * + * @param props + * @param logicalId + */ + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void { + if (this._cfnParameterMap.has(logicalId)) { + throw new Error('logical id already exists'); + } + this._cfnParameterMap.set(logicalId, new cdk.CfnParameter(this, logicalId, props)); + } + + renderCloudFormationTemplate = (): string => { + return JSONUtilities.stringify(this._toCloudFormation()); + }; + + generateAdminQueriesStack = (resourceName: string, authResourceName: string) => { + this._constructCfnPaths(resourceName); + + this.restApi = new apigw.CfnRestApi(this, resourceName, { + description: '', // TODO - left blank in current CLI + name: resourceName, + body: { + swagger: '2.0', + info: { + version: '2018-05-24T17:52:00Z', + title: resourceName, + }, + host: cdk.Fn.join('', ['apigateway.', cdk.Fn.ref('AWS::Region'), '.amazonaws.com']), + basePath: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', '/Prod', cdk.Fn.join('', ['/', cdk.Fn.ref('env')])), + schemes: ['https'], + paths: this.paths, + securityDefinitions: { + Cognito: { + type: 'apiKey', + name: 'Authorization', + in: 'header', + 'x-amazon-apigateway-authtype': 'cognito_user_pools', + 'x-amazon-apigateway-authorizer': { + providerARNs: [ + cdk.Fn.join('', [ + 'arn:aws:cognito-idp:', + cdk.Fn.ref('AWS::Region'), + ':', + cdk.Fn.ref('AWS::AccountId'), + ':userpool/', + cdk.Fn.ref(`auth${authResourceName}UserPoolId`), + ]), + ], + type: 'cognito_user_pools', + }, + }, + }, + definitions: { + Empty: { + type: 'object', + title: 'Empty Schema', + }, + }, + 'x-amazon-apigateway-request-validators': { + 'Validate query string parameters and headers': { + validateRequestParameters: true, + validateRequestBody: false, + }, + }, + }, + }); + + this._setDeploymentResource(resourceName); + }; + + generateStackResources = (resourceName: string) => { + this._constructCfnPaths(resourceName); + + this.restApi = new apigw.CfnRestApi(this, resourceName, { + description: '', // TODO - left blank in current CLI + failOnWarnings: true, + name: resourceName, + body: { + swagger: '2.0', + info: { + version: '2018-05-24T17:52:00Z', + title: resourceName, + }, + host: cdk.Fn.join('', ['apigateway.', cdk.Fn.ref('AWS::Region'), '.amazonaws.com']), + basePath: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', '/Prod', cdk.Fn.join('', ['/', cdk.Fn.ref('env')])), + schemes: ['https'], + paths: this.paths, + securityDefinitions: { + sigv4: { + type: 'apiKey', + name: 'Authorization', + in: 'header', + 'x-amazon-apigateway-authtype': 'awsSigv4', + }, + }, + definitions: { + RequestSchema: { + type: 'object', + required: ['request'], + properties: { + request: { + type: 'string', + }, + }, + title: 'Request Schema', + }, + ResponseSchema: { + type: 'object', + required: ['response'], + properties: { + response: { + type: 'string', + }, + }, + title: 'Response Schema', + }, + }, + }, + }); + + this._setDeploymentResource(resourceName); + }; + + private _constructCfnPaths(resourceName: string) { + for (const [pathName, path] of Object.entries(this._props.paths)) { + let lambdaPermissionLogicalId: string; + if (resourceName === 'AdminQueries') { + this.paths[`/{proxy+}`] = getAdminQueriesPathObject(path.lambdaFunction); + lambdaPermissionLogicalId = 'AdminQueriesAPIGWPolicyForLambda'; + } else { + this.paths[pathName] = getDefaultPathObject(path.lambdaFunction); + this.paths[`${pathName}/{proxy+}`] = getDefaultPathObject(path.lambdaFunction); + lambdaPermissionLogicalId = `function${path.lambdaFunction}Permission${resourceName}`; + } + + this.addLambdaPermissionCfnResource( + { + functionName: cdk.Fn.ref(`function${path.lambdaFunction}Name`), + action: 'lambda:InvokeFunction', + principal: 'apigateway.amazonaws.com', + sourceArn: cdk.Fn.join('', [ + 'arn:aws:execute-api:', + cdk.Fn.ref('AWS::Region'), + ':', + cdk.Fn.ref('AWS::AccountId'), + ':', + cdk.Fn.ref(resourceName), + '/*/*/*', + ]), + }, + lambdaPermissionLogicalId, + ); + } + } + + private _setDeploymentResource = (resourceName: string) => { + this.deploymentResource = new apigw.CfnDeployment(this, `DeploymentAPIGW${resourceName}`, { + restApiId: cdk.Fn.ref(resourceName), + stageName: cdk.Fn.conditionIf('ShouldNotCreateEnvResources', 'Prod', cdk.Fn.ref('env')).toString(), + }); + }; +} + +const getAdminQueriesPathObject = (lambdaFunctionName: string) => ({ + options: { + consumes: ['application/json'], + produces: ['application/json'], + responses: { + '200': { + description: '200 response', + schema: { + $ref: '#/definitions/Empty', + }, + headers: { + 'Access-Control-Allow-Origin': { + type: 'string', + }, + 'Access-Control-Allow-Methods': { + type: 'string', + }, + 'Access-Control-Allow-Headers': { + type: 'string', + }, + }, + }, + }, + 'x-amazon-apigateway-integration': { + responses: { + default: { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Methods': "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'method.response.header.Access-Control-Allow-Origin': "'*'", + }, + }, + }, + passthroughBehavior: 'when_no_match', + requestTemplates: { + 'application/json': '{"statusCode": 200}', + }, + type: 'mock', + }, + }, + 'x-amazon-apigateway-any-method': { + produces: ['application/json'], + parameters: [ + { + name: 'proxy', + in: 'path', + required: true, + type: 'string', + }, + { + name: 'Authorization', + in: 'header', + required: false, + type: 'string', + }, + ], + responses: {}, + security: [ + { + Cognito: ['aws.cognito.signin.user.admin'], + }, + ], + 'x-amazon-apigateway-request-validator': 'Validate query string parameters and headers', + 'x-amazon-apigateway-integration': { + uri: cdk.Fn.join('', [ + 'arn:aws:apigateway:', + cdk.Fn.ref('AWS::Region'), + ':lambda:path/2015-03-31/functions/', + cdk.Fn.ref(`function${lambdaFunctionName}Arn`), + '/invocations', + ]), + passthroughBehavior: 'when_no_match', + httpMethod: 'POST', + cacheNamespace: 'n40eb9', + cacheKeyParameters: ['method.request.path.proxy'], + contentHandling: 'CONVERT_TO_TEXT', + type: 'aws_proxy', + }, + }, +}); + +const getDefaultPathObject = (lambdaFunctionName: string) => ({ + options: { + consumes: ['application/json'], + produces: ['application/json'], + responses: { + '200': response200, + }, + 'x-amazon-apigateway-integration': { + responses: { + default: defaultCorsResponseObject, + }, + requestTemplates: { + 'application/json': '{"statusCode": 200}', + }, + passthroughBehavior: 'when_no_match', + type: 'mock', + }, + }, + 'x-amazon-apigateway-any-method': { + consumes: ['application/json'], + produces: ['application/json'], + parameters: [ + { + in: 'body', + name: 'RequestSchema', + required: false, + schema: { + $ref: '#/definitions/RequestSchema', + }, + }, + ], + responses: { + '200': { + description: '200 response', + schema: { + $ref: '#/definitions/ResponseSchema', + }, + }, + }, + 'x-amazon-apigateway-integration': { + responses: { + default: { + statusCode: '200', + }, + }, + uri: cdk.Fn.join('', [ + 'arn:aws:apigateway:', + cdk.Fn.ref('AWS::Region'), + ':lambda:path/2015-03-31/functions/', + cdk.Fn.ref(`function${lambdaFunctionName}Arn`), + '/invocations', + ]), + passthroughBehavior: 'when_no_match', + httpMethod: 'POST', + type: 'aws_proxy', + }, + }, +}); + +const defaultCorsResponseObject = { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Methods': "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + 'method.response.header.Access-Control-Allow-Headers': + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", + 'method.response.header.Access-Control-Allow-Origin': "'*'", + }, +}; + +const response200 = { + description: '200 response', + headers: { + 'Access-Control-Allow-Origin': { + type: 'string', + }, + 'Access-Control-Allow-Methods': { + type: 'string', + }, + 'Access-Control-Allow-Headers': { + type: 'string', + }, + }, +}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-transform.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-transform.ts new file mode 100644 index 00000000000..75f77944294 --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/apigw-stack-transform.ts @@ -0,0 +1,197 @@ +import * as cdk from '@aws-cdk/core'; +import { + $TSAny, + $TSContext, + AmplifyCategories, + buildOverrideDir, + getAmplifyResourceByCategories, + JSONUtilities, + PathConstants, + pathManager, + stateManager, + Template, + writeCFNTemplate, +} from 'amplify-cli-core'; +import { formatter, printer } from 'amplify-prompts'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { AmplifyApigwResourceStack, ApigwInputs } from '.'; +import { category } from '../../../category-constants'; +import { ApigwInputState } from '../apigw-input-state'; + +export class ApigwStackTransform { + _app: cdk.App; + cliInputs: ApigwInputs; + resourceTemplateObj: AmplifyApigwResourceStack | undefined; + cliInputsState: ApigwInputState; + cfn!: Template; + cfnInputParams!: {}; + resourceName: string; + + constructor(context: $TSContext, resourceName: string) { + this._app = new cdk.App(); + this.resourceName = resourceName; + + // Validate the cli-inputs.json for the resource + this.cliInputsState = ApigwInputState.getInstance(context, this.resourceName); + this.cliInputs = this.cliInputsState.getCliInputPayload(); + this.cliInputsState.isCLIInputsValid(); + } + + async transform() { + let authResourceName: string; + if (this.resourceName === 'AdminQueries') { + [authResourceName] = getAmplifyResourceByCategories(AmplifyCategories.AUTH).filter(resourceName => resourceName !== 'UserPoolGroups'); + } + + // Generate cloudformation stack from cli-inputs.json + this.generateStack(authResourceName); + + // Generate cloudformation stack input params from cli-inputs.json + this.generateCfnInputParameters(); + + // Modify cloudformation files based on overrides + await this.applyOverrides(); + + // Save generated cloudformation.json and parameters.json files + await this.saveBuildFiles(); + } + + // TODO generate params + generateCfnInputParameters() { + this.cfnInputParams = {}; + } + + generateStack(authResourceName?: string) { + this.resourceTemplateObj = new AmplifyApigwResourceStack(this._app, 'AmplifyApigwResourceStack', this.cliInputs); + + if (authResourceName) { + this.resourceTemplateObj.addCfnParameter( + { + type: 'String', + default: `auth${authResourceName}UserPoolId`, + }, + `auth${authResourceName}UserPoolId`, + ); + } + + // Add Parameters + for (const path of Object.values(this.cliInputs.paths)) { + this.resourceTemplateObj.addCfnParameter( + { + type: 'String', + default: `function${path.lambdaFunction}Name`, + }, + `function${path.lambdaFunction}Name`, + ); + this.resourceTemplateObj.addCfnParameter( + { + type: 'String', + default: `function${path.lambdaFunction}Arn`, + }, + `function${path.lambdaFunction}Arn`, + ); + } + + this.resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'env', + ); + + // Add conditions + this.resourceTemplateObj.addCfnCondition( + { + expression: cdk.Fn.conditionEquals(cdk.Fn.ref('env'), 'NONE'), + }, + 'ShouldNotCreateEnvResources', + ); + + // Add outputs + this.resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.join('', [ + 'https://', + this.cliInputsState.resourceName, + '.execute-api.', + cdk.Fn.ref('AWS::Region'), + '.amazonaws.com/', + cdk.Fn.conditionIf('ShouldNotCreateEnvResources', 'Prod', cdk.Fn.ref('env')) as unknown as string, + ]), + description: 'Root URL of the API gateway', + }, + 'RootUrl', + ); + + this.resourceTemplateObj.addCfnOutput( + { + value: this.resourceName, + description: 'API Friendly name', + }, + 'ApiName', + ); + + this.resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref(this.resourceName), + description: 'API ID (prefix of API URL)', + }, + 'ApiId', + ); + + // Add resources + this.resourceName === 'AdminQueries' + ? this.resourceTemplateObj.generateAdminQueriesStack(this.resourceName, authResourceName) + : this.resourceTemplateObj.generateStackResources(this.resourceName); + } + + async applyOverrides() { + const backendDir = pathManager.getBackendDirPath(); + const overrideFilePath = pathManager.getResourceDirectoryPath(undefined, AmplifyCategories.API, this.resourceName); + + const isBuild = await buildOverrideDir(backendDir, overrideFilePath).catch(error => { + printer.debug(`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 api"`]); + return undefined; + }); + + // TODO: Check Script Options + if (overrideProps && typeof overrideProps === 'function') { + try { + this.resourceTemplateObj = overrideProps(this.resourceTemplateObj); + + // 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: $TSAny) { + throw new Error(error); + } + } + } + } + + async saveBuildFiles() { + if (this.resourceTemplateObj) { + this.cfn = JSONUtilities.parse(this.resourceTemplateObj.renderCloudFormationTemplate()); + } + + const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, category, this.resourceName); + fs.ensureDirSync(resourceDirPath); + + const buildDirPath = path.join(resourceDirPath, PathConstants.BuildDirName); + fs.ensureDirSync(buildDirPath); + + stateManager.setResourceParametersJson(undefined, AmplifyCategories.API, this.resourceName, this.cfnInputParams); + + const cfnFilePath = path.resolve(path.join(buildDirPath, `${this.resourceName}-cloudformation-template.json`)); + return writeCFNTemplate(this.cfn, cfnFilePath); + } +} diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/index.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/index.ts new file mode 100644 index 00000000000..7b96c26fd5c --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/index.ts @@ -0,0 +1,3 @@ +export * from './apigw-stack-builder'; +export * from './apigw-stack-transform'; +export * from './types'; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts new file mode 100644 index 00000000000..2d01ec4b3fb --- /dev/null +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts @@ -0,0 +1,53 @@ +import * as cdk from '@aws-cdk/core'; +import * as apigwCdk from '@aws-cdk/aws-apigateway'; +import * as iamCdk from '@aws-cdk/aws-iam'; + +export type ApigwInputs = { + version: number; + paths: Path[]; +}; + +export type Path = { + lambdaFunction: string; + permissions: { + setting: PermissionSetting; + auth?: CrudOperation[]; + guest?: CrudOperation[]; + groups?: { [groupName: string]: CrudOperation[] }; + }; +}; + +export enum CrudOperation { + CREATE = 'CREATE', + READ = 'READ', + UPDATE = 'UPDATE', + DELETE = 'DELETE', +} + +export enum PermissionSetting { + PRIVATE = 'private', + PROTECTED = 'protected', + OPEN = 'open', +} + +type AmplifyCDKL1 = { + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void; + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void; + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void; + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void; + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void; +}; + +export type AmplifyApigwResourceTemplate = { + restApi: apigwCdk.CfnRestApi; + deploymentResource: apigwCdk.CfnDeployment; + policies?: { + [pathName: string]: ApigwPathPolicy; + }; +} & AmplifyCDKL1; + +export type ApigwPathPolicy = { + auth: iamCdk.CfnPolicy; + guest?: iamCdk.CfnPolicy; + groups?: { [groupName: string]: iamCdk.CfnPolicy }; +}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/cfn-api-artifact-handler.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/cfn-api-artifact-handler.ts index f06fc279414..3b5ed5d8c88 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/cfn-api-artifact-handler.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/cfn-api-artifact-handler.ts @@ -1,4 +1,4 @@ -import { isResourceNameUnique } from 'amplify-cli-core'; +import { $TSAny, $TSContext, isResourceNameUnique, JSONUtilities, pathManager, stateManager } from 'amplify-cli-core'; import { AddApiRequest, AppSyncServiceConfiguration, @@ -6,11 +6,12 @@ import { ResolutionStrategy, UpdateApiRequest, } from 'amplify-headless-interface'; +import { printer } from 'amplify-prompts'; import * as fs from 'fs-extra'; import { readTransformerConfiguration, TRANSFORM_CURRENT_VERSION, writeTransformerConfiguration } from 'graphql-transformer-core'; import _ from 'lodash'; import * as path from 'path'; -import uuid from 'uuid'; +import { v4 as uuid } from 'uuid'; import { category } from '../../category-constants'; import { ApiArtifactHandler, ApiArtifactHandlerOptions } from '../api-artifact-handler'; import { cfnParametersFilename, gqlSchemaFilename, provider, rootAssetDir } from './aws-constants'; @@ -22,7 +23,7 @@ import { conflictResolutionToResolverConfig } from './utils/resolver-config-to-c // keep in sync with ServiceName in amplify-category-function, but probably it will not change const FunctionServiceNameLambdaFunction = 'Lambda'; -export const getCfnApiArtifactHandler = (context): ApiArtifactHandler => { +export const getCfnApiArtifactHandler = (context: $TSContext): ApiArtifactHandler => { return new CfnApiArtifactHandler(context); }; const resolversDirName = 'resolvers'; @@ -35,16 +36,17 @@ const defaultCfnParameters = (apiName: string) => ({ DynamoDBEnableServerSideEncryption: false, }); class CfnApiArtifactHandler implements ApiArtifactHandler { - private readonly context: any; + private readonly context: $TSContext; - constructor(context) { + constructor(context: $TSContext) { this.context = context; } // TODO once the AddApiRequest contains multiple services this class should depend on an ApiArtifactHandler // for each service and delegate to the correct one createArtifacts = async (request: AddApiRequest): Promise => { - const existingApiName = getAppSyncResourceName(this.context.amplify.getProjectMeta()); + const meta = stateManager.getMeta(); + const existingApiName = getAppSyncResourceName(meta); if (existingApiName) { throw new Error(`GraphQL API ${existingApiName} already exists in the project. Use 'amplify update api' to make modifications.`); } @@ -98,7 +100,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { // for each service and delegate to the correct one updateArtifacts = async (request: UpdateApiRequest, opts?: ApiArtifactHandlerOptions): Promise => { const updates = request.serviceModification; - const apiName = getAppSyncResourceName(this.context.amplify.getProjectMeta()); + const apiName = getAppSyncResourceName(stateManager.getMeta()); if (!apiName) { throw new Error(`No AppSync API configured in the project. Use 'amplify add api' to create an API.`); } @@ -110,7 +112,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { updates.conflictResolution = await this.createResolverResources(updates.conflictResolution); await writeResolverConfig(updates.conflictResolution, resourceDir); } - const authConfig = getAppSyncAuthConfig(this.context.amplify.getProjectMeta()); + const authConfig = getAppSyncAuthConfig(stateManager.getMeta()); const oldConfigHadApiKey = authConfigHasApiKey(authConfig); if (updates.defaultAuthType) { authConfig.defaultAuthentication = appSyncAuthTypeToAuthConfig(updates.defaultAuthType); @@ -129,14 +131,14 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { this.context.amplify.updateamplifyMetaAfterResourceUpdate(category, apiName, 'output', { authConfig }); this.context.amplify.updateBackendConfigAfterResourceUpdate(category, apiName, 'output', { authConfig }); - printApiKeyWarnings(this.context, oldConfigHadApiKey, authConfigHasApiKey(authConfig)); + printApiKeyWarnings(oldConfigHadApiKey, authConfigHasApiKey(authConfig)); }; private writeSchema = (resourceDir: string, schema: string) => { fs.writeFileSync(path.join(resourceDir, gqlSchemaFilename), schema); }; - private getResourceDir = (apiName: string) => path.join(this.context.amplify.pathManager.getBackendDirPath(), category, apiName); + private getResourceDir = (apiName: string) => pathManager.getResourceDirectoryPath(undefined, category, apiName); private createAmplifyMeta = authConfig => ({ service: 'AppSync', @@ -180,8 +182,8 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { }; private getCfnParameters = (apiName: string, authConfig, resourceDir: string) => { - const params = - this.context.amplify.readJsonFile(path.join(resourceDir, cfnParametersFilename), undefined, false) || defaultCfnParameters(apiName); + const cfnPath = path.join(resourceDir, cfnParametersFilename); + const params = JSONUtilities.readJson<$TSAny>(cfnPath, { throwIfNotExist: false }) || defaultCfnParameters(apiName); const cognitoPool = this.getCognitoUserPool(authConfig); if (cognitoPool) { params.AuthCognitoUserPoolId = cognitoPool; @@ -198,7 +200,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { const defaultAuth = authConfig.defaultAuthentication || {}; if (defaultAuth.authenticationType === 'AMAZON_COGNITO_USER_POOLS' || additionalUserPoolProvider) { let userPoolId; - const configuredUserPoolName = checkIfAuthExists(this.context); + const configuredUserPoolName = checkIfAuthExists(); if (authConfig.userPoolConfig) { ({ userPoolId } = authConfig.userPoolConfig); @@ -217,7 +219,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { }; private createSyncFunction = async () => { - const targetDir = this.context.amplify.pathManager.getBackendDirPath(); + const targetDir = pathManager.getBackendDirPath(); const assetDir = path.normalize(path.join(rootAssetDir, 'sync-conflict-handler')); const [shortId] = uuid().split('-'); @@ -232,17 +234,17 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { { dir: assetDir, template: 'sync-conflict-handler-index.js.ejs', - target: `${targetDir}/function/${functionName}/src/index.js`, + target: path.join(targetDir, 'function', functionName, 'src', 'index.js'), }, { dir: assetDir, template: 'sync-conflict-handler-package.json.ejs', - target: `${targetDir}/function/${functionName}/src/package.json`, + target: path.join(targetDir, 'function', functionName, 'src', 'package.json'), }, { dir: assetDir, template: 'sync-conflict-handler-template.json.ejs', - target: `${targetDir}/function/${functionName}/${functionName}-cloudformation-template.json`, + target: path.join(targetDir, 'function', functionName, `${functionName}-cloudformation-template.json`), }, ]; @@ -256,7 +258,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { }; await this.context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); - this.context.print.success(`Successfully added ${functionName} function locally`); + printer.success(`Successfully added ${functionName} function locally`); return functionName + '-${env}'; }; @@ -268,7 +270,7 @@ class CfnApiArtifactHandler implements ApiArtifactHandler { * * write to the transformer conf if the resolverConfig is valid */ -export const writeResolverConfig = async (conflictResolution: ConflictResolution, resourceDir) => { +export const writeResolverConfig = async (conflictResolution: ConflictResolution, resourceDir: string) => { const localTransformerConfig = await readTransformerConfiguration(resourceDir); localTransformerConfig.ResolverConfig = conflictResolutionToResolverConfig(conflictResolution); await writeTransformerConfiguration(resourceDir, localTransformerConfig); diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/containers-handler.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/containers-handler.ts index 0a402bd123d..1bc866ab6c5 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/containers-handler.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/containers-handler.ts @@ -1,22 +1,22 @@ -import fs from 'fs-extra'; -import path from 'path'; -import uuid from 'uuid'; +import { $TSContext, createDefaultCustomPoliciesFile, pathManager } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { v4 as uuid } from 'uuid'; import { NETWORK_STACK_LOGICAL_ID } from '../../category-constants'; import { DEPLOYMENT_MECHANISM } from './base-api-stack'; import { GitHubSourceActionInfo } from './pipeline-with-awaiter'; import { API_TYPE, IMAGE_SOURCE_TYPE, ResourceDependency, ServiceConfiguration } from './service-walkthroughs/containers-walkthrough'; import { ApiResource, generateContainersArtifacts } from './utils/containers-artifacts'; -import { createDefaultCustomPoliciesFile, pathManager } from 'amplify-cli-core'; export const addResource = async ( serviceWalkthroughPromise: Promise, - context, - category, + context: $TSContext, + category: string, service, options, apiType: API_TYPE, ) => { - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); const walkthroughOptions = await serviceWalkthroughPromise; const { @@ -31,7 +31,7 @@ export const addResource = async ( dependsOn = [], mutableParametersState, } = walkthroughOptions; - const resourceDirPath = path.join(projectBackendDirPath, category, resourceName); + const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, category, resourceName); let [authName, updatedDependsOn] = await getResourceDependencies({ dependsOn, restrictAccess, category, resourceName, context }); @@ -88,36 +88,37 @@ export const addResource = async ( if (imageSource.type === IMAGE_SOURCE_TYPE.TEMPLATE) { fs.copySync( - path.join(__dirname, '../../../resources/awscloudformation/container-templates', imageSource.template), + path.join(__dirname, '..', '..', '..', 'resources', 'awscloudformation/container-templates', imageSource.template), path.join(resourceDirPath, 'src'), - { recursive: true } + { recursive: true }, ); const { exposedContainer } = await generateContainersArtifacts(context, apiResource); await context.amplify.updateamplifyMetaAfterResourceUpdate(category, options.resourceName, 'exposedContainer', exposedContainer); - } createDefaultCustomPoliciesFile(category, resourceName); const customPoliciesPath = pathManager.getCustomPoliciesPath(category, resourceName); - context.print.success(`Successfully added resource ${resourceName} locally.`); - context.print.info(''); - context.print.success('Next steps:'); + printer.success(`Successfully added resource ${resourceName} locally.`); + printer.info(''); + printer.success('Next steps:'); if (deploymentMechanism === DEPLOYMENT_MECHANISM.FULLY_MANAGED) { - context.print.info(`- Place your Dockerfile, docker-compose.yml and any related container source files in "amplify/backend/api/${resourceName}/src"`); + printer.info( + `- Place your Dockerfile, docker-compose.yml and any related container source files in "amplify/backend/api/${resourceName}/src"`, + ); } else if (deploymentMechanism === DEPLOYMENT_MECHANISM.INDENPENDENTLY_MANAGED) { - context.print.info( + printer.info( `- Ensure you have the Dockerfile, docker-compose.yml and any related container source files in your Github path: ${gitHubInfo.path}`, ); } - context.print.info( + printer.info( `- Amplify CLI infers many configuration settings from the "docker-compose.yaml" file. Learn more: docs.amplify.aws/cli/usage/containers`, ); - context.print.info(`- To access AWS resources outside of this Amplify app, edit the ${customPoliciesPath}`); - context.print.info('- Run "amplify push" to build and deploy your image'); + printer.info(`- To access AWS resources outside of this Amplify app, edit the ${customPoliciesPath}`); + printer.info('- Run "amplify push" to build and deploy your image'); return resourceName; }; @@ -131,7 +132,7 @@ const getResourceDependencies = async ({ }: { restrictAccess: boolean; dependsOn: ResourceDependency[]; - context: any; + context: $TSContext; category: string; resourceName: string; }) => { @@ -165,7 +166,7 @@ const getResourceDependencies = async ({ apiRequirements, ]); } catch (e) { - context.print.error(e); + printer.error(e); throw e; } } else { @@ -192,7 +193,7 @@ const getResourceDependencies = async ({ return [authName, updatedDependsOn]; }; -export const updateResource = async (serviceWalkthroughPromise: Promise, context, category) => { +export const updateResource = async (serviceWalkthroughPromise: Promise, context: $TSContext, category: string) => { const options = await serviceWalkthroughPromise; const { diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.js b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.ts similarity index 68% rename from packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.js rename to packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.ts index 7ab876f8fa1..60dbcd9da47 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.js +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/apigw-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import { v4 as uuid } from 'uuid'; -const getAllDefaults = project => { +export const getAllDefaults = (project: { projectConfig: { projectName: string } }) => { const name = project.projectConfig.projectName.toLowerCase().replace(/[^0-9a-zA-Z]/gi, ''); const [shortId] = uuid().split('-'); const defaults = { @@ -11,7 +11,3 @@ const getAllDefaults = project => { return defaults; }; - -module.exports = { - getAllDefaults, -}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.js b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.ts similarity index 71% rename from packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.js rename to packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.ts index 0d5cb71570a..8c48966c22c 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.js +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/appSync-defaults.ts @@ -1,6 +1,7 @@ -const uuid = require('uuid'); +import { $TSMeta } from 'amplify-cli-core'; +import { v4 as uuid } from 'uuid'; -const getAllDefaults = project => { +export const getAllDefaults = (project: { amplifyMeta: $TSMeta; projectConfig: { projectName: string } }) => { const name = project.projectConfig.projectName.toLowerCase(); const region = project.amplifyMeta.providers.awscloudformation.Region; const [shortId] = uuid().split('-'); @@ -16,7 +17,3 @@ const getAllDefaults = project => { return defaults; }; - -module.exports = { - getAllDefaults, -}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/containers-defaults.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/containers-defaults.ts index da44caf946f..0f77dee83e9 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/containers-defaults.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/default-values/containers-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import { v4 as uuid } from 'uuid'; -const getAllDefaults = project => { +export const getAllDefaults = () => { const [shortId] = uuid().split('-'); const defaults = { resourceName: `container${shortId}`, @@ -8,7 +8,3 @@ const getAllDefaults = project => { return defaults; }; - -module.exports = { - getAllDefaults, -}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/docker-compose/index.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/docker-compose/index.ts index 39f92df12e2..a66729ca105 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/docker-compose/index.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/docker-compose/index.ts @@ -1,3 +1 @@ -import { getContainers } from "./converter"; - -export { getContainers }; \ No newline at end of file +export { getContainers } from './converter'; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/ecs-alb-stack.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/ecs-alb-stack.ts index c66f3719603..01ca720b389 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/ecs-alb-stack.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/ecs-alb-stack.ts @@ -7,8 +7,8 @@ import * as elb2 from '@aws-cdk/aws-elasticloadbalancingv2'; import * as route53 from '@aws-cdk/aws-route53'; import * as route53targets from '@aws-cdk/aws-route53-targets'; import * as cdk from '@aws-cdk/core'; -import { ContainersStack, ContainersStackProps } from './base-api-stack'; import { v4 as uuid } from 'uuid'; +import { ContainersStack, ContainersStackProps } from './base-api-stack'; type EcsStackProps = ContainersStackProps & Readonly<{ diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/index.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/index.ts index 4ee7ce6fee8..f4563957d0c 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/index.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/index.ts @@ -1,28 +1,52 @@ -import { serviceWalkthroughResultToAddApiRequest } from './utils/service-walkthrough-result-to-add-api-request'; -import { getCfnApiArtifactHandler } from './cfn-api-artifact-handler'; -import { serviceMetadataFor, getServiceWalkthrough, datasourceMetadataFor } from './utils/dynamic-imports'; -import { legacyAddResource } from './legacy-add-resource'; -import { legacyUpdateResource } from './legacy-update-resource'; +import { $TSAny, $TSContext, $TSObject, AmplifySupportedService, exitOnNextTick, NotImplementedError } from 'amplify-cli-core'; import { UpdateApiRequest } from 'amplify-headless-interface'; -import { editSchemaFlow } from './utils/edit-schema-flow'; -import { NotImplementedError, exitOnNextTick } from 'amplify-cli-core'; -import { addResource as addContainer, updateResource as updateContainer } from './containers-handler'; +import { printer } from 'amplify-prompts'; import inquirer from 'inquirer'; +import * as path from 'path'; +import { category } from '../../category-constants'; +import { ApigwInputState } from './apigw-input-state'; +import { getCfnApiArtifactHandler } from './cfn-api-artifact-handler'; +import { addResource as addContainer, updateResource as updateContainer } from './containers-handler'; +import { legacyAddResource } from './legacy-add-resource'; import { API_TYPE, - ServiceConfiguration, getPermissionPolicies as getContainerPermissionPolicies, + ServiceConfiguration, } from './service-walkthroughs/containers-walkthrough'; -import { category } from '../../category-constants'; +import { datasourceMetadataFor, getServiceWalkthrough, serviceMetadataFor } from './utils/dynamic-imports'; +import { editSchemaFlow } from './utils/edit-schema-flow'; +import { serviceWalkthroughResultToAddApiRequest } from './utils/service-walkthrough-result-to-add-api-request'; + +export async function addAdminQueriesApi( + context: $TSContext, + apiProps: { apiName: string; functionName: string; authResourceName: string; dependsOn: $TSObject[] }, +) { + const apigwInputState = ApigwInputState.getInstance(context, apiProps.apiName); + return apigwInputState.addAdminQueriesResource(apiProps); +} + +export async function updateAdminQueriesApi( + context: $TSContext, + apiProps: { apiName: string; functionName: string; authResourceName: string; dependsOn: $TSObject[] }, +) { + const apigwInputState = ApigwInputState.getInstance(context, apiProps.apiName); + // Check for migration + + if (!apigwInputState.cliInputsFileExists()) { + await apigwInputState.migrateAdminQueries(apiProps); + } else { + return apigwInputState.addAdminQueriesResource(apiProps); + } +} -export async function console(context, service) { +export async function console(context: $TSContext, service: string) { const { serviceWalkthroughFilename } = await serviceMetadataFor(service); - const serviceWalkthroughSrc = `${__dirname}/service-walkthroughs/${serviceWalkthroughFilename}`; - const { openConsole } = require(serviceWalkthroughSrc); + const serviceWalkthroughSrc = path.join(__dirname, 'service-walkthroughs', serviceWalkthroughFilename); + const { openConsole } = await import(serviceWalkthroughSrc); if (!openConsole) { const errMessage = 'Opening console functionality not available for this option'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new NotImplementedError(errMessage)); exitOnNextTick(0); } @@ -30,24 +54,23 @@ export async function console(context, service) { return openConsole(context); } -async function addContainerResource(context, category, service, options, apiType) { +async function addContainerResource(context: $TSContext, service: string, options, apiType: API_TYPE) { const serviceWalkthroughFilename = 'containers-walkthrough.js'; - const defaultValuesFilename = 'containers-defaults.js'; const serviceWalkthrough = await getServiceWalkthrough(serviceWalkthroughFilename); - const serviceWalkthroughPromise: Promise = serviceWalkthrough(context, defaultValuesFilename, apiType); + const serviceWalkthroughPromise: Promise<$TSAny> = serviceWalkthrough(context, apiType); return await addContainer(serviceWalkthroughPromise, context, category, service, options, apiType); } -async function addNonContainerResource(context, category, service, options) { +async function addNonContainerResource(context: $TSContext, service: string, options) { const serviceMetadata = await serviceMetadataFor(service); - const { serviceWalkthroughFilename, defaultValuesFilename } = serviceMetadata; + const { serviceWalkthroughFilename } = serviceMetadata; const serviceWalkthrough = await getServiceWalkthrough(serviceWalkthroughFilename); - const serviceWalkthroughPromise: Promise = serviceWalkthrough(context, defaultValuesFilename, serviceMetadata); + const serviceWalkthroughPromise: Promise<$TSAny> = serviceWalkthrough(context, serviceMetadata); switch (service) { - case 'AppSync': + case AmplifySupportedService.APPSYNC: const walkthroughResult = await serviceWalkthroughPromise; const askToEdit = walkthroughResult.askToEdit; const apiName = await getCfnApiArtifactHandler(context).createArtifacts(serviceWalkthroughResultToAddApiRequest(walkthroughResult)); @@ -55,23 +78,26 @@ async function addNonContainerResource(context, category, service, options) { await editSchemaFlow(context, apiName); } return apiName; + case AmplifySupportedService.APIGW: + const apigwInputState = ApigwInputState.getInstance(context); + return apigwInputState.addApigwResource(serviceWalkthroughPromise, options); default: return legacyAddResource(serviceWalkthroughPromise, context, category, service, options); } } -export async function addResource(context, category, service, options) { +export async function addResource(context: $TSContext, service: string, options) { let useContainerResource = false; let apiType = API_TYPE.GRAPHQL; if (isContainersEnabled(context)) { switch (service) { - case 'AppSync': - useContainerResource = await isGraphQLContainer(context); + case AmplifySupportedService.APPSYNC: + useContainerResource = await isGraphQLContainer(); apiType = API_TYPE.GRAPHQL; break; - case 'API Gateway': - useContainerResource = await isRestContainer(context); + case AmplifySupportedService.APIGW: + useContainerResource = await isRestContainer(); apiType = API_TYPE.REST; break; default: @@ -80,11 +106,11 @@ export async function addResource(context, category, service, options) { } return useContainerResource - ? addContainerResource(context, category, service, options, apiType) - : addNonContainerResource(context, category, service, options); + ? addContainerResource(context, service, options, apiType) + : addNonContainerResource(context, service, options); } -function isContainersEnabled(context) { +function isContainersEnabled(context: $TSContext) { const { frontend } = context.amplify.getProjectConfig(); if (frontend) { const { config: { ServerlessContainers = false } = {} } = context.amplify.getProjectConfig()[frontend] || {}; @@ -95,14 +121,14 @@ function isContainersEnabled(context) { return false; } -async function isGraphQLContainer(context): Promise { +async function isGraphQLContainer(): Promise { const { graphqlSelection } = await inquirer.prompt({ name: 'graphqlSelection', message: 'Which service would you like to use', type: 'list', choices: [ { - name: 'AppSync', + name: AmplifySupportedService.APPSYNC, value: false, }, { @@ -115,7 +141,7 @@ async function isGraphQLContainer(context): Promise { return graphqlSelection; } -async function isRestContainer(context) { +async function isRestContainer() { const { restSelection } = await inquirer.prompt({ name: 'restSelection', message: 'Which service would you like to use', @@ -135,22 +161,18 @@ async function isRestContainer(context) { return restSelection; } -export async function updateResource(context, category, service, options) { +export async function updateResource(context: $TSContext, category: string, service: string, options) { const allowContainers = options?.allowContainers ?? true; let useContainerResource = false; let apiType = API_TYPE.GRAPHQL; if (allowContainers && isContainersEnabled(context)) { - const { - hasAPIGatewayContainerResource, - hasAPIGatewayLambdaResource, - hasGraphQLAppSyncResource, - hasGraphqlContainerResource, - } = await describeApiResourcesBySubCategory(context); + const { hasAPIGatewayContainerResource, hasAPIGatewayLambdaResource, hasGraphQLAppSyncResource, hasGraphqlContainerResource } = + await describeApiResourcesBySubCategory(context); switch (service) { - case 'AppSync': + case AmplifySupportedService.APPSYNC: if (hasGraphQLAppSyncResource && hasGraphqlContainerResource) { - useContainerResource = await isGraphQLContainer(context); + useContainerResource = await isGraphQLContainer(); } else if (hasGraphqlContainerResource) { useContainerResource = true; } else { @@ -158,9 +180,9 @@ export async function updateResource(context, category, service, options) { } apiType = API_TYPE.GRAPHQL; break; - case 'API Gateway': + case AmplifySupportedService.APIGW: if (hasAPIGatewayContainerResource && hasAPIGatewayLambdaResource) { - useContainerResource = await isRestContainer(context); + useContainerResource = await isRestContainer(); } else if (hasAPIGatewayContainerResource) { useContainerResource = true; } else { @@ -173,12 +195,10 @@ export async function updateResource(context, category, service, options) { } } - return useContainerResource - ? updateContainerResource(context, category, service, apiType) - : updateNonContainerResource(context, category, service); + return useContainerResource ? updateContainerResource(context, category, service, apiType) : updateNonContainerResource(context, service); } -async function describeApiResourcesBySubCategory(context) { +async function describeApiResourcesBySubCategory(context: $TSContext) { const { allResources } = await context.amplify.getResourceStatus(); const resources = allResources.filter(resource => resource.category === category && resource.mobileHubMigrated !== true); @@ -191,9 +211,9 @@ async function describeApiResourcesBySubCategory(context) { hasAPIGatewayContainerResource = hasAPIGatewayContainerResource || (resource.service === 'ElasticContainer' && resource.apiType === API_TYPE.REST); - hasAPIGatewayLambdaResource = hasAPIGatewayLambdaResource || resource.service === 'API Gateway'; + hasAPIGatewayLambdaResource = hasAPIGatewayLambdaResource || resource.service === AmplifySupportedService.APIGW; - hasGraphQLAppSyncResource = hasGraphQLAppSyncResource || resource.service === 'AppSync'; + hasGraphQLAppSyncResource = hasGraphQLAppSyncResource || resource.service === AmplifySupportedService.APPSYNC; hasGraphqlContainerResource = hasGraphqlContainerResource || (resource.service === 'ElasticContainer' && resource.apiType === API_TYPE.GRAPHQL); @@ -207,33 +227,32 @@ async function describeApiResourcesBySubCategory(context) { }; } -async function updateContainerResource(context, category, service, apiType: API_TYPE) { +async function updateContainerResource(context: $TSContext, category: string, service: string, apiType: API_TYPE) { const serviceWalkthroughFilename = 'containers-walkthrough'; - const defaultValuesFilename = 'containers-defaults.js'; - const serviceWalkthroughSrc = `${__dirname}/service-walkthroughs/${serviceWalkthroughFilename}`; - const { updateWalkthrough } = require(serviceWalkthroughSrc); + const serviceWalkthroughSrc = path.join(__dirname, 'service-walkthroughs', serviceWalkthroughFilename); + const { updateWalkthrough } = await import(serviceWalkthroughSrc); if (!updateWalkthrough) { const errMessage = 'Update functionality not available for this option'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new NotImplementedError(errMessage)); exitOnNextTick(0); } - const updateWalkthroughPromise: Promise = updateWalkthrough(context, defaultValuesFilename, apiType); + const updateWalkthroughPromise: Promise = updateWalkthrough(context, apiType); updateContainer(updateWalkthroughPromise, context, category); } -async function updateNonContainerResource(context, category, service) { +async function updateNonContainerResource(context: $TSContext, service: string) { const serviceMetadata = await serviceMetadataFor(service); const { defaultValuesFilename, serviceWalkthroughFilename } = serviceMetadata; - const serviceWalkthroughSrc = `${__dirname}/service-walkthroughs/${serviceWalkthroughFilename}`; - const { updateWalkthrough } = require(serviceWalkthroughSrc); + const serviceWalkthroughSrc = path.join(__dirname, 'service-walkthroughs', serviceWalkthroughFilename); + const { updateWalkthrough } = await import(serviceWalkthroughSrc); if (!updateWalkthrough) { const errMessage = 'Update functionality not available for this option'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new NotImplementedError(errMessage)); exitOnNextTick(0); } @@ -241,14 +260,15 @@ async function updateNonContainerResource(context, category, service) { const updateWalkthroughPromise: Promise = updateWalkthrough(context, defaultValuesFilename, serviceMetadata); switch (service) { - case 'AppSync': + case AmplifySupportedService.APPSYNC: return updateWalkthroughPromise.then(getCfnApiArtifactHandler(context).updateArtifacts); default: - return legacyUpdateResource(updateWalkthroughPromise, context, category, service); + const apigwInputState = ApigwInputState.getInstance(context); + return apigwInputState.updateApigwResource(updateWalkthroughPromise); } } -export async function migrateResource(context, projectPath, service, resourceName) { +export async function migrateResource(context: $TSContext, projectPath: string, service: string, resourceName: string) { if (service === 'ElasticContainer') { return migrateResourceContainer(context, projectPath, service, resourceName); } else { @@ -256,53 +276,53 @@ export async function migrateResource(context, projectPath, service, resourceNam } } -async function migrateResourceContainer(context, projectPath, service, resourceName) { - context.print.info(`No migration required for ${resourceName}`); +async function migrateResourceContainer(context: $TSContext, projectPath: string, service: string, resourceName: string) { + printer.info(`No migration required for ${resourceName}`); return; } -async function migrateResourceNonContainer(context, projectPath, service, resourceName) { +async function migrateResourceNonContainer(context: $TSContext, projectPath: string, service: string, resourceName: string) { const serviceMetadata = await serviceMetadataFor(service); const { serviceWalkthroughFilename } = serviceMetadata; - const serviceWalkthroughSrc = `${__dirname}/service-walkthroughs/${serviceWalkthroughFilename}`; - const { migrate } = require(serviceWalkthroughSrc); + const serviceWalkthroughSrc = path.join(__dirname, 'service-walkthroughs', serviceWalkthroughFilename); + const { migrate } = await import(serviceWalkthroughSrc); if (!migrate) { - context.print.info(`No migration required for ${resourceName}`); + printer.info(`No migration required for ${resourceName}`); return; } return await migrate(context, projectPath, resourceName); } -export async function addDatasource(context, category, datasource) { +export async function addDatasource(context: $TSContext, category, datasource) { const serviceMetadata = await datasourceMetadataFor(datasource); - const { defaultValuesFilename, serviceWalkthroughFilename } = serviceMetadata; - return (await getServiceWalkthrough(serviceWalkthroughFilename))(context, defaultValuesFilename, serviceMetadata); + const { serviceWalkthroughFilename } = serviceMetadata; + return (await getServiceWalkthrough(serviceWalkthroughFilename))(context, serviceMetadata); } -export async function getPermissionPolicies(context, service, resourceName, crudOptions) { +export async function getPermissionPolicies(context: $TSContext, service: string, resourceName: string, crudOptions) { if (service === 'ElasticContainer') { return getPermissionPoliciesContainer(context, service, resourceName, crudOptions); } else { - return getPermissionPoliciesNonContainer(context, service, resourceName, crudOptions); + return getPermissionPoliciesNonContainer(service, resourceName, crudOptions); } } -async function getPermissionPoliciesContainer(context, service, resourceName, crudOptions) { +async function getPermissionPoliciesContainer(context: $TSContext, service: string, resourceName: string, crudOptions) { return getContainerPermissionPolicies(context, service, resourceName, crudOptions); } -async function getPermissionPoliciesNonContainer(context, service, resourceName, crudOptions) { +async function getPermissionPoliciesNonContainer(service: string, resourceName: string, crudOptions: string[]) { const serviceMetadata = await serviceMetadataFor(service); const { serviceWalkthroughFilename } = serviceMetadata; - const serviceWalkthroughSrc = `${__dirname}/service-walkthroughs/${serviceWalkthroughFilename}`; - const { getIAMPolicies } = require(serviceWalkthroughSrc); + const serviceWalkthroughSrc = path.join(__dirname, 'service-walkthroughs', serviceWalkthroughFilename); + const { getIAMPolicies } = await import(serviceWalkthroughSrc); if (!getIAMPolicies) { - context.print.info(`No policies found for ${resourceName}`); + printer.info(`No policies found for ${resourceName}`); return; } - return getIAMPolicies(resourceName, crudOptions, context); + return getIAMPolicies(resourceName, crudOptions); } diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts index ff7e33a1890..29ced6ba3b4 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-add-resource.ts @@ -1,4 +1,4 @@ -import { isResourceNameUnique, JSONUtilities } from 'amplify-cli-core'; +import { $TSAny, $TSContext, $TSObject, isResourceNameUnique, JSONUtilities, pathManager } from 'amplify-cli-core'; import * as fs from 'fs-extra'; import * as path from 'path'; import { cfnParametersFilename, parametersFileName, rootAssetDir } from './aws-constants'; @@ -6,10 +6,15 @@ import { serviceMetadataFor } from './utils/dynamic-imports'; // this is the old logic for generating resources in the project directory // it is still used for adding REST APIs -export const legacyAddResource = async (serviceWalkthroughPromise: Promise, context, category, service, options) => { +export const legacyAddResource = async ( + serviceWalkthroughPromise: Promise<$TSAny>, + context: $TSContext, + category: string, + service: string, + options: $TSObject, +) => { let answers; let { cfnFilename } = await serviceMetadataFor(service); - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); const result = await serviceWalkthroughPromise; @@ -30,7 +35,7 @@ export const legacyAddResource = async (serviceWalkthroughPromise: Promise, copyCfnTemplate(context, category, answers, cfnFilename); const parameters = { ...answers }; - const resourceDirPath = path.join(projectBackendDirPath, category, parameters.resourceName); + const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, category, parameters.resourceName); isResourceNameUnique(category, parameters.resourceName); @@ -47,15 +52,14 @@ export const legacyAddResource = async (serviceWalkthroughPromise: Promise, }; // exported because the update flow still uses this method directly for now -export const copyCfnTemplate = (context, category, options, cfnFilename) => { - const { amplify } = context; - const targetDir = amplify.pathManager.getBackendDirPath(); +export const copyCfnTemplate = (context: $TSContext, category: string, options, cfnFilename) => { + const resourceDirPath = pathManager.getResourceDirectoryPath(undefined, category, options.resourceName); const copyJobs = [ { dir: path.join(rootAssetDir, 'cloudformation-templates'), template: cfnFilename, - target: `${targetDir}/${category}/${options.resourceName}/${options.resourceName}-cloudformation-template.json`, + target: path.join(resourceDirPath, `${options.resourceName}-cloudformation-template.json`), }, ]; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-update-resource.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-update-resource.ts index 99a15a9365f..930523a24ad 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-update-resource.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/legacy-update-resource.ts @@ -1,15 +1,15 @@ -import { serviceMetadataFor } from './utils/dynamic-imports'; -import { copyCfnTemplate, addPolicyResourceNameToPaths } from './legacy-add-resource'; -import fs from 'fs-extra'; -import path from 'path'; +import { $TSAny, $TSContext, JSONUtilities, pathManager } from 'amplify-cli-core'; +import * as fs from 'fs-extra'; +import * as path from 'path'; import { parametersFileName } from './aws-constants'; +import { addPolicyResourceNameToPaths, copyCfnTemplate } from './legacy-add-resource'; +import { serviceMetadataFor } from './utils/dynamic-imports'; -export const legacyUpdateResource = async (updateWalkthroughPromise: Promise, context, category, service) => { +export const legacyUpdateResource = async (updateWalkthroughPromise: Promise<$TSAny>, context: $TSContext, category: string, service) => { let answers; let { cfnFilename } = await serviceMetadataFor(service); - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); const result = await updateWalkthroughPromise; - const options: any = {}; + const options: $TSAny = {}; if (result) { if (result.answers) { ({ answers } = result); @@ -25,11 +25,10 @@ export const legacyUpdateResource = async (updateWalkthroughPromise: Promise; + +export type ApigwAnswers = { + paths: { [pathName: string]: ApigwPath }; + resourceName: string; + functionArns?: string[]; + dependsOn?: $TSObject[]; +}; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/apigw-walkthrough.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/apigw-walkthrough.ts index ac3390df3e3..38f61d52899 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/apigw-walkthrough.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/apigw-walkthrough.ts @@ -1,113 +1,95 @@ -import { $TSContext, exitOnNextTick, isResourceNameUnique, open, ResourceDoesNotExistError, stateManager } from 'amplify-cli-core'; -import * as fs from 'fs-extra'; +import { + $TSAny, + $TSContext, + $TSObject, + AmplifyCategories, + AmplifySupportedService, + exitOnNextTick, + isResourceNameUnique, + open, + pathManager, + ResourceDoesNotExistError, + stateManager, +} from 'amplify-cli-core'; +import { printer, prompter } from 'amplify-prompts'; import inquirer from 'inquirer'; import os from 'os'; -import * as path from 'path'; -import uuid from 'uuid'; -import { rootAssetDir } from '../aws-constants'; +import { v4 as uuid } from 'uuid'; +import { ApigwInputState } from '../apigw-input-state'; +import { CrudOperation, PermissionSetting } from '../cdk-stack-builder'; +import { getAllDefaults } from '../default-values/apigw-defaults'; +import { ApigwAnswers, ApigwPath, ApigwWalkthroughReturnPromise, ApiRequirements } from '../service-walkthrough-types/apigw-types'; import { checkForPathOverlap, formatCFNPathParamsForExpressJs, validatePathName } from '../utils/rest-api-path-utils'; -// keep in sync with ServiceName in amplify-category-function, but probably it will not change -const FunctionServiceNameLambdaFunction = 'Lambda'; - -const category = 'api'; -const serviceName = 'API Gateway'; +const category = AmplifyCategories.API; +const serviceName = AmplifySupportedService.APIGW; const elasticContainerServiceName = 'ElasticContainer'; -const parametersFileName = 'api-params.json'; -const cfnParametersFilename = 'parameters.json'; - -export async function serviceWalkthrough(context, defaultValuesFilename) { - const { amplify } = context; - const defaultValuesSrc = `${__dirname}/../default-values/${defaultValuesFilename}`; - const { getAllDefaults } = await import(defaultValuesSrc); - const allDefaultValues = getAllDefaults(amplify.getProjectDetails()); - let answers = { - paths: [], - }; +export async function serviceWalkthrough(context: $TSContext): ApigwWalkthroughReturnPromise { + const allDefaultValues = getAllDefaults(context.amplify.getProjectDetails()); - const apiNames = await askApiNames(context, allDefaultValues); - answers = { ...answers, ...apiNames }; + const resourceName = await askApiName(context, allDefaultValues.resourceName); + const answers = { paths: {}, resourceName, dependsOn: undefined }; return pathFlow(context, answers); } -export async function updateWalkthrough(context, defaultValuesFilename) { - const { amplify } = context; +export async function updateWalkthrough(context: $TSContext) { const { allResources } = await context.amplify.getResourceStatus(); - const defaultValuesSrc = `${__dirname}/../default-values/${defaultValuesFilename}`; - const { getAllDefaults } = await import(defaultValuesSrc); - const allDefaultValues = getAllDefaults(amplify.getProjectDetails()); + const allDefaultValues = getAllDefaults(context.amplify.getProjectDetails()); const resources = allResources .filter(resource => resource.service === serviceName && resource.mobileHubMigrated !== true) .map(resource => resource.resourceName); - // There can only be one appsync resource if (resources.length === 0) { - const errMessage = 'No REST API resource to update. Please use "amplify add api" command to create a new REST API'; - context.print.error(errMessage); + const errMessage = 'No REST API resource to update. Use "amplify add api" command to create a new REST API'; + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); return; } - let answers: any = { + let answers: $TSAny = { paths: [], }; - const question = [ - { - name: 'resourceName', - message: 'Please select the REST API you would want to update', - type: 'list', - choices: resources, - }, - { - name: 'operation', - message: 'What would you like to do', - type: 'list', - when: context.input.command !== 'add', - choices: [ - { name: 'Add another path', value: 'add' }, - { name: 'Update path', value: 'update' }, - { name: 'Remove path', value: 'remove' }, - ], - }, - ]; - - const updateApi = await inquirer.prompt(question); + const selectedApiName = await prompter.pick<'one', string>('Select the REST API you want to update:', resources); + let updateApiOperation = await prompter.pick<'one', string>('What would you like to do?', [ + { name: 'Add another path', value: 'add' }, + { name: 'Update path', value: 'update' }, + { name: 'Remove path', value: 'remove' }, + ]); // Inquirer does not currently support combining 'when' and 'default', so // manually set the operation if the user ended up here via amplify api add. if (context.input.command === 'add') { - updateApi.operation = 'add'; + updateApiOperation = 'add'; } - if (updateApi.resourceName === 'AdminQueries') { + if (selectedApiName === 'AdminQueries') { const errMessage = `The Admin Queries API is maintained through the Auth category and should be updated using 'amplify update auth' command`; - context.print.warning(errMessage); + printer.warn(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); } - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - const resourceDirPath = path.join(projectBackendDirPath, category, updateApi.resourceName as string); - const parametersFilePath = path.join(resourceDirPath, parametersFileName); - let parameters; - try { - parameters = context.amplify.readJsonFile(parametersFilePath); - } catch (e) { - parameters = {}; + const projRoot = pathManager.findProjectRoot(); + if (!stateManager.resourceInputsJsonExists(projRoot, category, selectedApiName)) { + // Not yet migrated + console.log(selectedApiName); + await migrate(context, projRoot, selectedApiName); } - parameters.resourceName = updateApi.resourceName; + + const parameters = stateManager.getResourceInputsJson(projRoot, category, selectedApiName); + parameters.resourceName = selectedApiName; Object.assign(allDefaultValues, parameters); answers = { ...answers, ...parameters }; [answers.uuid] = uuid().split('-'); - const pathList = answers.paths.map(path => path.name); + const pathNames = Object.keys(answers.paths); let updatedResult = {}; - switch (updateApi.operation) { + switch (updateApiOperation) { case 'add': { updatedResult = pathFlow(context, answers); break; @@ -115,76 +97,52 @@ export async function updateWalkthrough(context, defaultValuesFilename) { case 'remove': { const pathToRemove = await inquirer.prompt({ name: 'path', - message: 'Please select the path you would want to remove', + message: 'Select the path you would want to remove', type: 'list', - choices: pathList, + choices: pathNames, }); - answers.paths = answers.paths.filter(path => path.name !== pathToRemove.path); + delete answers.paths[pathToRemove.path]; - const { dependsOn, functionArns } = await findDependsOn(answers.paths, context); + const { dependsOn, functionArns } = await findDependsOn(answers.paths); answers.dependsOn = dependsOn; answers.functionArns = functionArns; - updatedResult = { answers, dependsOn }; + updatedResult = { answers }; break; } case 'update': { const pathToEdit = await inquirer.prompt({ - name: 'path', - message: 'Please select the path you would want to edit', + name: 'pathName', + message: 'Select the path you would want to edit', type: 'list', - choices: pathList, + choices: pathNames, }); // removing path from paths list - const currentPath = answers.paths.find(path => path.name === pathToEdit.path); - answers.paths = answers.paths.filter(path => path.name !== pathToEdit.path); + const currentPath: ApigwPath = answers.paths[pathToEdit.pathName]; + delete answers.paths[pathToEdit.pathName]; updatedResult = pathFlow(context, answers, currentPath); break; } default: { - updatedResult = {}; + throw new Error(`Unrecognized API update operation "${updateApiOperation}"`); } } return updatedResult; } -async function pathFlow(context, answers, currentPath?) { +async function pathFlow(context: $TSContext, answers: ApigwAnswers, currentPath?: ApigwPath): ApigwWalkthroughReturnPromise { const pathsAnswer = await askPaths(context, answers, currentPath); - answers = { ...answers, paths: pathsAnswer.paths, functionArns: pathsAnswer.functionArns }; - const { dependsOn } = pathsAnswer; - const privacy = { - auth: pathsAnswer.paths.filter(path => path.privacy.auth && path.privacy.auth.length > 0).length, - unauth: pathsAnswer.paths.filter(path => path.privacy.unauth && path.privacy.unauth.length > 0).length, - }; - - answers = { ...answers, privacy, dependsOn }; - - if ( - context.amplify.getProjectDetails() && - context.amplify.getProjectDetails().amplifyMeta && - context.amplify.getProjectDetails().amplifyMeta.providers && - context.amplify.getProjectDetails().amplifyMeta.providers.awscloudformation - ) { - // TODO: read from utility functions (Dustin PR) - const { amplifyMeta } = context.amplify.getProjectDetails(); - const providerInfo = amplifyMeta.providers.awscloudformation; - - answers.privacy.authRoleName = providerInfo.AuthRoleName; - answers.privacy.unAuthRoleName = providerInfo.UnauthRoleName; - } - - return { answers, dependsOn }; + return { answers: pathsAnswer }; } -async function askApiNames(context, defaults) { - const { amplify } = context; +async function askApiName(context: $TSContext, defaultResourceName: string) { const apiNameValidator = (input: string) => { - const amplifyValidatorOutput = amplify.inputValidation({ + const amplifyValidatorOutput = context.amplify.inputValidation({ validation: { operator: 'regex', value: '^[a-zA-Z0-9]+$', @@ -202,111 +160,90 @@ async function askApiNames(context, defaults) { return typeof amplifyValidatorOutput === 'string' ? amplifyValidatorOutput : uniqueCheck; }; - const answer: { apiName?: string; resourceName: string } = await inquirer.prompt([ - { - name: 'resourceName', - type: 'input', - message: 'Provide a friendly name for your resource to be used as a label for this category in the project:', - default: defaults.resourceName, - validate: apiNameValidator, - }, - ]); - - answer.apiName = answer.resourceName; + const resourceName = await prompter.input<'one', string>( + 'Provide a friendly name for your resource to be used as a label for this category in the project:', + { initial: defaultResourceName, validate: apiNameValidator }, + ); - return answer; + return resourceName; } -async function askPrivacy(context, answers, currentPath) { +async function askPermissions( + context: $TSContext, + answers: $TSObject, + currentPath: ApigwPath, +): Promise<{ setting?: PermissionSetting; auth?: CrudOperation[]; open?: boolean; userPoolGroups?: $TSObject; unauth?: CrudOperation[] }> { while (true) { - const apiAccess = await inquirer.prompt({ - name: 'restrict', - type: 'confirm', - default: !(currentPath && currentPath.open), - message: 'Restrict API access', - }); + const apiAccess = await prompter.yesOrNo('Restrict API access', currentPath?.permissions?.setting !== PermissionSetting.OPEN); - if (!apiAccess.restrict) { - return { open: true }; + if (!apiAccess) { + return { setting: PermissionSetting.OPEN }; } - const userPoolGroupList = await context.amplify.getUserPoolGroupList(context); + const userPoolGroupList = context.amplify.getUserPoolGroupList(); let permissionSelected = 'Auth/Guest Users'; - const privacy: any = {}; + const permissions: $TSAny = {}; if (userPoolGroupList.length > 0) { do { if (permissionSelected === 'Learn more') { - context.print.info(''); - context.print.info( - 'You can restrict access using CRUD policies for Authenticated Users, Guest Users, or on individual Group that users belong to in a User Pool. If a user logs into your application and is not a member of any group they will use policy set for “Authenticated Users”, however if they belong to a group they will only get the policy associated with that specific group.', + printer.blankLine(); + printer.info( + 'You can restrict access using CRUD policies for Authenticated Users, Guest Users, or on individual Group that users belong to' + + ' in a User Pool. If a user logs into your application and is not a member of any group they will use policy set for ' + + '“Authenticated Users”, however if they belong to a group they will only get the policy associated with that specific group.', ); - context.print.info(''); + printer.blankLine(); } - const permissionSelection = await inquirer.prompt({ - name: 'selection', - type: 'list', - message: 'Restrict access by?', - choices: ['Auth/Guest Users', 'Individual Groups', 'Both', 'Learn more'], - default: 'Auth/Guest Users', - }); - - permissionSelected = permissionSelection.selection; + const permissionSelection = await prompter.pick<'one', string>('Restrict access by?', [ + 'Auth/Guest Users', + 'Individual Groups', + 'Both', + 'Learn more', + ]); + + permissionSelected = permissionSelection; } while (permissionSelected === 'Learn more'); } if (permissionSelected === 'Both' || permissionSelected === 'Auth/Guest Users') { - const answer = await inquirer.prompt({ - name: 'privacy', - type: 'list', - message: 'Who should have access?', - choices: [ + const permissionSetting = await prompter.pick<'one', string>( + 'Who should have access?', + [ { name: 'Authenticated users only', - value: 'private', + value: PermissionSetting.PRIVATE, }, { name: 'Authenticated and Guest users', - value: 'protected', + value: PermissionSetting.PROTECTED, }, ], - default: currentPath && currentPath.privacy && currentPath.privacy.protected ? 'protected' : 'private', - }); + { initial: currentPath?.permissions?.setting === PermissionSetting.PROTECTED ? 1 : 0 }, + ); - privacy[answer.privacy] = true; - - context.api = { - privacy: answer.privacy, - }; + permissions.setting = permissionSetting; let { - privacy: { auth: authPrivacy }, - } = currentPath || { privacy: {} }; + permissions: { auth: authPermissions }, + } = currentPath || { permissions: { auth: [] } }; let { - privacy: { unauth: unauthPrivacy }, - } = currentPath || { privacy: {} }; - - // convert legacy permissions to CRUD structure - if (authPrivacy && ['r', 'rw'].includes(authPrivacy)) { - authPrivacy = convertToCRUD(authPrivacy); - } - if (unauthPrivacy && ['r', 'rw'].includes(unauthPrivacy)) { - unauthPrivacy = convertToCRUD(unauthPrivacy); - } + permissions: { unauth: unauthPermissions }, + } = currentPath || { permissions: { unauth: [] } }; - if (answer.privacy === 'private') { - privacy.auth = await askReadWrite('Authenticated', context, authPrivacy); + if (permissionSetting === PermissionSetting.PRIVATE) { + permissions.auth = await askCRUD('Authenticated', authPermissions); - const apiRequirements = { authSelections: 'identityPoolAndUserPool' }; + const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool' }; await ensureAuth(context, apiRequirements, answers.resourceName); } - if (answer.privacy === 'protected') { - privacy.auth = await askReadWrite('Authenticated', context, authPrivacy); - privacy.unauth = await askReadWrite('Guest', context, unauthPrivacy); - const apiRequirements = { authSelections: 'identityPoolAndUserPool', allowUnauthenticatedIdentities: true }; + if (permissionSetting === PermissionSetting.PROTECTED) { + permissions.auth = await askCRUD('Authenticated', authPermissions); + permissions.unauth = await askCRUD('Guest', unauthPermissions); + const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool', allowUnauthenticatedIdentities: true }; await ensureAuth(context, apiRequirements, answers.resourceName); } @@ -315,60 +252,53 @@ async function askPrivacy(context, answers, currentPath) { if (permissionSelected === 'Both' || permissionSelected === 'Individual Groups') { // Enable Auth if not enabled - const apiRequirements = { authSelections: 'identityPoolAndUserPool' }; + const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool' }; await ensureAuth(context, apiRequirements, answers.resourceName); // Get Auth resource name - const authResourceName = await getAuthResourceName(context); + const authResourceName = getAuthResourceName(); answers.authResourceName = authResourceName; let defaultSelectedGroups = []; - if (currentPath && currentPath.privacy && currentPath.privacy.userPoolGroups) { - defaultSelectedGroups = Object.keys(currentPath.privacy.userPoolGroups); + if (currentPath?.permissions?.userPoolGroups) { + defaultSelectedGroups = Object.keys(currentPath.permissions.userPoolGroups); } - const userPoolGroupSelection = await inquirer.prompt([ - { - name: 'userpoolGroups', - type: 'checkbox', - message: 'Select groups:', - choices: userPoolGroupList, - default: defaultSelectedGroups, - validate: inputs => { - if (inputs.length === 0) { - return 'Select at least one option'; - } - return true; - }, + const userPoolGroupSelection = await inquirer.prompt({ + name: 'userpoolGroups', + type: 'checkbox', + message: 'Select groups:', + choices: userPoolGroupList, + default: defaultSelectedGroups, + validate: inputs => { + if (inputs.length === 0) { + return 'Select at least one option'; + } + return true; }, - ]); + }); const selectedUserPoolGroupList = userPoolGroupSelection.userpoolGroups; - for (let i = 0; i < selectedUserPoolGroupList.length; i += 1) { + for (const selectedUserPoolGroup of selectedUserPoolGroupList) { let defaults = []; - if ( - currentPath && - currentPath.privacy && - currentPath.privacy.userPoolGroups && - currentPath.privacy.userPoolGroups[selectedUserPoolGroupList[i]] - ) { - defaults = currentPath.privacy.userPoolGroups[selectedUserPoolGroupList[i]]; + if (currentPath?.permissions?.userPoolGroups?.[selectedUserPoolGroup]) { + defaults = currentPath.permissions.userPoolGroups[selectedUserPoolGroup]; } - if (!privacy.userPoolGroups) { - privacy.userPoolGroups = {}; + if (!permissions.userPoolGroups) { + permissions.userPoolGroups = {}; } - privacy.userPoolGroups[selectedUserPoolGroupList[i]] = await askReadWrite(selectedUserPoolGroupList[i], context, defaults); + permissions.userPoolGroups[selectedUserPoolGroup] = await askCRUD(selectedUserPoolGroup, defaults); } } - return privacy; + return permissions; } } -async function ensureAuth(context, apiRequirements, resourceName) { - const checkResult = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'checkRequirements', [ +async function ensureAuth(context: $TSContext, apiRequirements: ApiRequirements, resourceName: string) { + const checkResult: $TSAny = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'checkRequirements', [ apiRequirements, context, 'api', @@ -382,7 +312,7 @@ async function ensureAuth(context, apiRequirements, resourceName) { } if (checkResult.errors && checkResult.errors.length > 0) { - context.print.warning(checkResult.errors.join(os.EOL)); + printer.warn(checkResult.errors.join(os.EOL)); } // If auth is not imported and there were errors, adjust or enable auth configuration @@ -390,61 +320,36 @@ async function ensureAuth(context, apiRequirements, resourceName) { try { await context.amplify.invokePluginMethod(context, 'auth', undefined, 'externalAuthEnable', [ context, - 'api', + AmplifyCategories.API, resourceName, apiRequirements, ]); } catch (error) { - context.print.error(error); + printer.error(error); throw error; } } } -async function askReadWrite(userType, context, privacy) { - const permissionMap = { - create: ['/POST'], - read: ['/GET'], - update: ['/PUT', '/PATCH'], - delete: ['/DELETE'], - }; - - const defaults = []; - if (privacy) { - Object.values(permissionMap).forEach((el, index) => { - if (el.every(i => privacy.includes(i))) { - defaults.push(Object.keys(permissionMap)[index]); - } - }); - } - - const crudAnswers = await context.amplify.crudFlow(userType, permissionMap, defaults); +async function askCRUD(userType: string, permissions: string[] = []) { + const crudOptions = ['create', 'read', 'update', 'delete']; + const crudAnswers = await prompter.pick<'many', string>(`What permissions do you want to grant to ${userType}`, crudOptions, { + returnSize: 'many', + initial: permissions.map(p => crudOptions.indexOf(p)), + }); return crudAnswers; } -async function askPaths(context, answers, currentPath) { - // const existingLambdaArns = true; - - const existingFunctions = functionsExist(context); +async function askPaths(context: $TSContext, answers: $TSObject, currentPath: ApigwPath): Promise { + const existingFunctions = functionsExist(); - const choices = [ - { - name: 'Create a new Lambda function', - value: 'newFunction', - }, - ]; - - /* - Removing this option for now in favor of multi-env support - - NOT CRITICAL - if (existingLambdaArns) { - choices.push({ - name: 'Use a Lambda function already deployed on AWS', - value: 'arn', - }); - } - */ + let defaultFunctionType = 'newFunction'; + const defaultChoice = { + name: 'Create a new Lambda function', + value: defaultFunctionType, + }; + const choices = [defaultChoice]; if (existingFunctions) { choices.push({ @@ -453,28 +358,19 @@ async function askPaths(context, answers, currentPath) { }); } - let defaultFunctionType = 'newFunction'; - if (currentPath) { - defaultFunctionType = currentPath.lambdaArn ? 'arn' : 'projectFunction'; - } - - const paths = [...answers.paths]; + const paths = answers.paths; - let addAnotherPath; + let addAnotherPath: boolean; do { - let pathName; - let isPathValid; + let pathName: string; + let isPathValid: boolean; do { - const pathAnswer = await inquirer.prompt({ - name: 'name', - type: 'input', - message: 'Provide a path (e.g., /book/{isbn}):', - default: currentPath ? currentPath.name : '/items', - validate: value => validatePathName(value), + pathName = await prompter.input('Provide a path (e.g., /book/{isbn}):', { + initial: currentPath ? currentPath.name : '/items', + validate: validatePathName, }); - pathName = pathAnswer.name; - const overlapCheckResult = checkForPathOverlap(pathName, paths); + const overlapCheckResult = checkForPathOverlap(pathName, Object.keys(paths)); if (overlapCheckResult === false) { // The path provided by the user is valid, and doesn't overlap with any other endpoints that they've stood up with API Gateway. isPathValid = true; @@ -483,81 +379,64 @@ async function askPaths(context, answers, currentPath) { // Ask them if they're okay with this. If they are, then we'll consider their provided path to be valid. const higherOrderPath = overlapCheckResult.higherOrderPath; const lowerOrderPath = overlapCheckResult.lowerOrderPath; - isPathValid = ( - await inquirer.prompt({ - name: 'isOverlappingPathOK', - type: 'confirm', - message: `The path ${lowerOrderPath} overlaps with ${higherOrderPath}. Users authorized to access ${higherOrderPath} will also have access to ${lowerOrderPath}. Are you sure you want to continue?`, - default: false, - }) - ).isOverlappingPathOK; + + isPathValid = await prompter.confirmContinue( + `The path ${lowerOrderPath} overlaps with ${higherOrderPath}. Users authorized to access ${higherOrderPath} will also have access` + + ` to ${lowerOrderPath}. Are you sure you want to continue?`, + ); } } while (!isPathValid); - const lambdaAnswer = await inquirer.prompt({ - name: 'functionType', - type: 'list', - message: 'Choose a Lambda source', - choices, - default: defaultFunctionType, - }); + const functionType = await prompter.pick<'one', string>('Choose a Lambda source', choices, { initial: choices.indexOf(defaultChoice) }); - // TODO: add path validation like awsmobile-cli does let path = { name: pathName }; let lambda; do { - lambda = await askLambdaSource(context, lambdaAnswer.functionType, path.name, currentPath); + lambda = await askLambdaSource(context, functionType, pathName, currentPath); } while (!lambda); - const privacy = await askPrivacy(context, answers, currentPath); - path = { ...path, ...lambda, privacy }; - paths.push(path); + const permissions = await askPermissions(context, answers, currentPath); + path = { ...path, ...lambda, permissions }; + paths[pathName] = path; if (currentPath) { break; } - addAnotherPath = ( - await inquirer.prompt({ - name: 'anotherPath', - type: 'confirm', - message: 'Do you want to add another path?', - default: false, - }) - ).anotherPath; + addAnotherPath = await prompter.confirmContinue('Do you want to add another path?'); } while (addAnotherPath); - const { dependsOn, functionArns } = await findDependsOn(paths, context); + const { dependsOn, functionArns } = await findDependsOn(paths); - return { paths, dependsOn, functionArns }; + return { paths, dependsOn, resourceName: answers.resourceName, functionArns }; } -async function findDependsOn(paths, context) { +async function findDependsOn(paths: $TSObject[]) { // go thru all paths and add lambdaFunctions to dependsOn and functionArns uniquely const dependsOn = []; const functionArns = []; - for (let i = 0; i < paths.length; i += 1) { - if (paths[i].lambdaFunction && !paths[i].lambdaArn) { - if (!dependsOn.find(func => func.resourceName === paths[i].lambdaFunction)) { + for (const path of Object.values(paths)) { + if (path.lambdaFunction && !path.lambdaArn) { + if (!dependsOn.find(func => func.resourceName === path.lambdaFunction)) { dependsOn.push({ category: 'function', - resourceName: paths[i].lambdaFunction, + resourceName: path.lambdaFunction, attributes: ['Name', 'Arn'], }); } } - if (!functionArns.find(func => func.lambdaFunction === paths[i].lambdaFunction)) { + if (!functionArns.find(func => func.lambdaFunction === path.lambdaFunction)) { functionArns.push({ - lambdaFunction: paths[i].lambdaFunction, - lambdaArn: paths[i].lambdaArn, + lambdaFunction: path.lambdaFunction, + lambdaArn: path.lambdaArn, }); } - if (paths[i].privacy && paths[i].privacy.userPoolGroups) { - const userPoolGroups = Object.keys(paths[i].privacy.userPoolGroups); + if (path?.permissions?.userPoolGroups) { + const userPoolGroups = Object.keys(path.privacy.userPoolGroups); if (userPoolGroups.length > 0) { // Get auth resource name - const authResourceName = await getAuthResourceName(context); + const authResourceName = getAuthResourceName(); if (!dependsOn.find(resource => resource.resourceName === authResourceName)) { dependsOn.push({ @@ -582,26 +461,27 @@ async function findDependsOn(paths, context) { return { dependsOn, functionArns }; } -async function getAuthResourceName(context) { - let authResources = (await context.amplify.getResourceStatus('auth')).allResources; - authResources = authResources.filter(resource => resource.service === 'Cognito'); +function getAuthResourceName(): string { + const meta = stateManager.getMeta(); + const authResources = (meta?.auth || []).filter(resource => resource.service === AmplifySupportedService.COGNITO); if (authResources.length === 0) { - throw new Error('No auth resource found. Please add it using amplify add auth'); + throw new Error('No auth resource found. Add it using amplify add auth'); } const authResourceName = authResources[0].resourceName; return authResourceName; } -function functionsExist(context) { - if (!context.amplify.getProjectDetails().amplifyMeta.function) { +function functionsExist() { + const meta = stateManager.getMeta(); + if (!meta.function) { return false; } - const functionResources = context.amplify.getProjectDetails().amplifyMeta.function; + const functionResources = meta.function; const lambdaFunctions = []; Object.keys(functionResources).forEach(resourceName => { - if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) { + if (functionResources[resourceName].service === AmplifySupportedService.LAMBDA) { lambdaFunctions.push(resourceName); } }); @@ -613,26 +493,20 @@ function functionsExist(context) { return true; } -async function askLambdaSource(context, functionType, path, currentPath) { +async function askLambdaSource(context: $TSContext, functionType: string, path: string, currentPath: ApigwPath) { switch (functionType) { case 'arn': return askLambdaArn(context, currentPath); case 'projectFunction': - return askLambdaFromProject(context, currentPath); + return askLambdaFromProject(currentPath); case 'newFunction': - return newLambdaFunction(context, path); + return newLambdaFunction(context as $TSAny, path); default: throw new Error('Type not supported'); } } -async function newLambdaFunction(context, path) { - context.api = { - path, - // ExpressJS represents path parameters as /:param instead of /{param}. This expression performs this replacement. - expressPath: formatCFNPathParamsForExpressJs(path), - functionTemplate: 'serverless', - }; +async function newLambdaFunction(context: $TSContext, path: string) { let params = { functionTemplate: { parameters: { @@ -642,39 +516,35 @@ async function newLambdaFunction(context, path) { }, }; - const resourceName = await context.amplify.invokePluginMethod(context, 'function', undefined, 'add', [ + const resourceName = await context.amplify.invokePluginMethod(context, AmplifyCategories.FUNCTION, undefined, 'add', [ context, 'awscloudformation', - FunctionServiceNameLambdaFunction, + AmplifySupportedService.LAMBDA, params, ]); - context.print.success('Succesfully added the Lambda function locally'); + printer.success('Succesfully added the Lambda function locally'); return { lambdaFunction: resourceName }; } -async function askLambdaFromProject(context, currentPath) { - const functionResources = context.amplify.getProjectDetails().amplifyMeta.function; +async function askLambdaFromProject(currentPath?: ApigwPath) { + const meta = stateManager.getMeta(); const lambdaFunctions = []; - Object.keys(functionResources).forEach(resourceName => { - if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) { + Object.keys(meta?.function || {}).forEach(resourceName => { + if (meta.function[resourceName].service === AmplifySupportedService.LAMBDA) { lambdaFunctions.push(resourceName); } }); - const answer = await inquirer.prompt({ - name: 'lambdaFunction', - type: 'list', - message: 'Choose the Lambda function to invoke by this path', - choices: lambdaFunctions, - default: currentPath ? currentPath.lambdaFunction : lambdaFunctions[0], + const lambdaFunction = await prompter.pick<'one', string>('Choose the Lambda function to invoke by this path', lambdaFunctions, { + initial: currentPath ? lambdaFunctions.indexOf(currentPath.lambdaFunction) : 0, }); - return { lambdaFunction: answer.lambdaFunction }; + return { lambdaFunction }; } -async function askLambdaArn(context, currentPath) { +async function askLambdaArn(context: $TSContext, currentPath?: ApigwPath) { const lambdaFunctions = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getLambdaFunctions'); const lambdaOptions = lambdaFunctions.map(lambdaFunction => ({ @@ -683,16 +553,16 @@ async function askLambdaArn(context, currentPath) { })); if (lambdaOptions.length === 0) { - context.print.error('You do not have any Lambda functions configured for the selected Region'); + printer.error('You do not have any Lambda functions configured for the selected Region'); return null; } const lambdaCloudOptionQuestion = { type: 'list', name: 'lambdaChoice', - message: 'Please select a Lambda function', + message: 'Select a Lambda function', choices: lambdaOptions, - default: currentPath && currentPath.lambdaArn ? `${currentPath.lambdaArn}` : `${lambdaOptions[0].value}`, + default: currentPath && currentPath.lambdaFunction ? `${currentPath.lambdaFunction}` : `${lambdaOptions[0].value}`, }; let lambdaOption; @@ -700,7 +570,7 @@ async function askLambdaArn(context, currentPath) { try { lambdaOption = await inquirer.prompt([lambdaCloudOptionQuestion]); } catch (err) { - context.print.error('Select a Lambda Function'); + printer.error('Select a Lambda Function'); } } @@ -712,56 +582,12 @@ async function askLambdaArn(context, currentPath) { }; } -export async function migrate(context, projectPath, resourceName) { - const { amplify } = context; - - const targetDir = amplify.pathManager.getBackendDirPath(); - const resourceDirPath = path.join(targetDir, category, resourceName); - const parametersFilePath = path.join(resourceDirPath, parametersFileName); - let parameters; - try { - parameters = amplify.readJsonFile(parametersFilePath); - } catch (e) { - context.print.error(`Error reading api-params.json file for ${resourceName} resource`); - throw e; - } - const copyJobs = [ - { - dir: path.join(rootAssetDir, 'cloudformation-templates'), - template: 'apigw-cloudformation-template-default.json.ejs', - target: `${targetDir}/${category}/${resourceName}/${resourceName}-cloudformation-template.json`, - }, - ]; - - // copy over the files - await context.amplify.copyBatch(context, copyJobs, parameters, true, false); - - // Create parameters.json file - const cfnParameters = { - authRoleName: { - Ref: 'AuthRoleName', - }, - unauthRoleName: { - Ref: 'UnauthRoleName', - }, - }; - - const cfnParametersFilePath = path.join(resourceDirPath, cfnParametersFilename); - const jsonString = JSON.stringify(cfnParameters, null, 4); - fs.writeFileSync(cfnParametersFilePath, jsonString, 'utf8'); -} - -function convertToCRUD(privacy) { - if (privacy === 'r') { - privacy = ['/GET']; - } else if (privacy === 'rw') { - privacy = ['/POST', '/GET', '/PUT', '/PATCH', '/DELETE']; - } - - return privacy; +export async function migrate(context: $TSContext, projectPath: string, resourceName: string) { + const apigwInputState = ApigwInputState.getInstance(context, resourceName); + return apigwInputState.migrateApigwResource(resourceName); } -export function getIAMPolicies(resourceName, crudOptions) { +export function getIAMPolicies(resourceName: string, crudOptions: string[]) { let policy = {}; const actions = []; @@ -780,7 +606,7 @@ export function getIAMPolicies(resourceName, crudOptions) { actions.push('apigateway:DELETE'); break; default: - console.log(`${crudOption} not supported`); + printer.info(`${crudOption} not supported`); } }); @@ -812,7 +638,7 @@ export function getIAMPolicies(resourceName, crudOptions) { return { policy, attributes }; } -export const openConsole = async (context: $TSContext) => { +export const openConsole = async (context?: $TSContext) => { const amplifyMeta = stateManager.getMeta(); const categoryAmplifyMeta = amplifyMeta[category]; const { Region } = amplifyMeta.providers.awscloudformation; @@ -830,12 +656,7 @@ export const openConsole = async (context: $TSContext) => { let selectedApi = restApis[0]; if (restApis.length > 1) { - ({ selectedApi } = await inquirer.prompt({ - type: 'list', - name: 'selectedApi', - choices: restApis, - message: 'Please select the API', - })); + selectedApi = await prompter.pick<'one', string>('Select the API', restApis); } const selectedResource = categoryAmplifyMeta[selectedApi]; @@ -853,34 +674,29 @@ export const openConsole = async (context: $TSContext) => { const codePipeline = 'CodePipeline'; const elasticContainer = 'ElasticContainer'; - const { selectedConsole } = await inquirer.prompt({ - name: 'selectedConsole', - message: 'Which console you want to open', - type: 'list', - choices: [ - { - name: 'Elastic Container Service (Deployed container status)', - value: elasticContainer, - }, - { - name: 'CodePipeline (Container build status)', - value: codePipeline, - }, - ], - }); + const selectedConsole = await prompter.pick<'one', string>('Which console you want to open', [ + { + name: 'Elastic Container Service (Deployed container status)', + value: elasticContainer, + }, + { + name: 'CodePipeline (Container build status)', + value: codePipeline, + }, + ]); if (selectedConsole === elasticContainer) { url = `https://console.aws.amazon.com/ecs/home?region=${Region}#/clusters/${ClusterName}/services/${ServiceName}/details`; } else if (selectedConsole === codePipeline) { url = `https://${Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${PipelineName}/view`; } else { - context.print.error('Option not available'); + printer.error('Option not available'); return; } } open(url, { wait: false }); } else { - context.print.error('There are no REST APIs pushed to the cloud'); + printer.error('There are no REST APIs pushed to the cloud'); } }; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-rds-walkthrough.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-rds-walkthrough.ts index 1d0a92fb11f..10d0b2eac01 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-rds-walkthrough.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-rds-walkthrough.ts @@ -1,21 +1,22 @@ -import inquirer from 'inquirer'; +import { $TSContext, $TSObject, exitOnNextTick, ResourceCredentialsNotFoundError, ResourceDoesNotExistError } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; import chalk from 'chalk'; -import ora from 'ora'; import { DataApiParams } from 'graphql-relational-schema-transformer'; -import { ResourceDoesNotExistError, ResourceCredentialsNotFoundError, exitOnNextTick, $TSContext, $TSObject } from 'amplify-cli-core'; +import inquirer from 'inquirer'; +import ora from 'ora'; const spinner = ora(''); const category = 'api'; const providerName = 'awscloudformation'; -export async function serviceWalkthrough(context: $TSContext, defaultValuesFilename: string, datasourceMetadata: $TSObject) { +export async function serviceWalkthrough(context: $TSContext, datasourceMetadata: $TSObject) { const amplifyMeta = context.amplify.getProjectMeta(); // Verify that an API exists in the project before proceeding. if (amplifyMeta == null || amplifyMeta[category] == null || Object.keys(amplifyMeta[category]).length === 0) { const errMessage = 'You must create an AppSync API in your project before adding a graphql datasource. Please use "amplify api add" to create the API.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); } @@ -24,9 +25,9 @@ export async function serviceWalkthrough(context: $TSContext, defaultValuesFilen let appSyncApi: string; const apis = Object.keys(amplifyMeta[category]); - for (let i = 0; i < apis.length; i += 1) { - if (amplifyMeta[category][apis[i]].service === 'AppSync') { - appSyncApi = apis[i]; + for (const api of apis) { + if (amplifyMeta[category][api].service === 'AppSync') { + appSyncApi = api; break; } } @@ -35,7 +36,7 @@ export async function serviceWalkthrough(context: $TSContext, defaultValuesFilen if (!appSyncApi) { const errMessage = 'You must create an AppSync API in your project before adding a graphql datasource. Please use "amplify api add" to create the API.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); } @@ -86,7 +87,7 @@ async function selectCluster(context: $TSContext, inputs, AWS) { if (serverlessClusters.length === 0) { const errMessage = 'No properly configured Aurora Serverless clusters found.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); @@ -110,7 +111,7 @@ async function selectCluster(context: $TSContext, inputs, AWS) { // Pick first and only value const firstCluster = Array.from(clusters.values())[0]; - context.print.info(`${chalk.green('✔')} Only one Cluster was found: '${firstCluster.DBClusterIdentifier}' was automatically selected.`); + printer.info(`${chalk.green('✔')} Only one Cluster was found: '${firstCluster.DBClusterIdentifier}' was automatically selected.`); return { selectedClusterArn: firstCluster.DBClusterArn, @@ -148,7 +149,7 @@ async function getSecretStoreArn(context: $TSContext, inputs, clusterResourceId, if (secretsForCluster.length === 0) { const errMessage = 'No RDS access credentials found in the AWS Secrect Manager.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceCredentialsNotFoundError(errMessage)); @@ -169,7 +170,7 @@ async function getSecretStoreArn(context: $TSContext, inputs, clusterResourceId, // Pick first and only value selectedSecretArn = Array.from(secrets.values())[0]; - context.print.info(`${chalk.green('✔')} Only one Secret was found for the cluster: '${selectedSecretArn}' was automatically selected.`); + printer.info(`${chalk.green('✔')} Only one Secret was found for the cluster: '${selectedSecretArn}' was automatically selected.`); } return selectedSecretArn; @@ -206,14 +207,14 @@ async function selectDatabase(context: $TSContext, inputs, clusterArn, secretArn const msg = `Ensure that '${secretArn}' contains your database credentials. ` + 'Please note that Aurora Serverless does not support IAM database authentication.'; - context.print.error(msg); + printer.error(msg); } } if (databaseList.length === 0) { const errMessage = 'No database found in the selected cluster.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); @@ -224,7 +225,7 @@ async function selectDatabase(context: $TSContext, inputs, clusterArn, secretArn return await promptWalkthroughQuestion(inputs, 3, databaseList); } - context.print.info(`${chalk.green('✔')} Only one Database was found: '${databaseList[0]}' was automatically selected.`); + printer.info(`${chalk.green('✔')} Only one Database was found: '${databaseList[0]}' was automatically selected.`); return databaseList[0]; } diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts index 933134e813d..508160615fe 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/appSync-walkthrough.ts @@ -1,30 +1,33 @@ -import { ListQuestion, CheckboxQuestion, ListChoiceOptions } from 'inquirer'; -import { dataStoreLearnMore } from '../sync-conflict-handler-assets/syncAssets'; -import inquirer from 'inquirer'; -import fs from 'fs-extra'; -import path from 'path'; -import { rootAssetDir, provider } from '../aws-constants'; -import { collectDirectivesByTypeNames, readProjectConfiguration } from 'graphql-transformer-core'; -import { category } from '../../../category-constants'; -import { UpdateApiRequest } from '../../../../../amplify-headless-interface/lib/interface/api/update'; -import { authConfigToAppSyncAuthType } from '../utils/auth-config-to-app-sync-auth-type-bi-di-mapper'; -import { resolverConfigToConflictResolution } from '../utils/resolver-config-to-conflict-resolution-bi-di-mapper'; -import _ from 'lodash'; -import chalk from 'chalk'; -import uuid from 'uuid'; -import { getAppSyncAuthConfig, checkIfAuthExists, authConfigHasApiKey } from '../utils/amplify-meta-utils'; +import { Duration, Expiration } from '@aws-cdk/core'; import { - ResourceAlreadyExistsError, - ResourceDoesNotExistError, - UnknownResourceTypeError, + $TSContext, + $TSObject, exitOnNextTick, - stateManager, FeatureFlags, - $TSContext, open, + pathManager, + ResourceAlreadyExistsError, + ResourceDoesNotExistError, + stateManager, + UnknownResourceTypeError, } from 'amplify-cli-core'; -import { Duration, Expiration } from '@aws-cdk/core'; +import { UpdateApiRequest } from 'amplify-headless-interface'; +import { printer } from 'amplify-prompts'; +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import { collectDirectivesByTypeNames, readProjectConfiguration } from 'graphql-transformer-core'; +import inquirer, { CheckboxQuestion, ListChoiceOptions, ListQuestion } from 'inquirer'; +import _ from 'lodash'; +import * as path from 'path'; +import { v4 as uuid } from 'uuid'; +import { category } from '../../../category-constants'; +import { rootAssetDir } from '../aws-constants'; +import { getAllDefaults } from '../default-values/appSync-defaults'; +import { dataStoreLearnMore } from '../sync-conflict-handler-assets/syncAssets'; +import { authConfigHasApiKey, checkIfAuthExists, getAppSyncAuthConfig } from '../utils/amplify-meta-utils'; +import { authConfigToAppSyncAuthType } from '../utils/auth-config-to-app-sync-auth-type-bi-di-mapper'; import { defineGlobalSandboxMode } from '../utils/global-sandbox-mode'; +import { resolverConfigToConflictResolution } from '../utils/resolver-config-to-conflict-resolution-bi-di-mapper'; const serviceName = 'AppSync'; const elasticContainerServiceName = 'ElasticContainer'; @@ -151,11 +154,11 @@ export const openConsole = async (context: $TSContext) => { url = `https://console.aws.amazon.com/appsync/home?region=${Region}#/${GraphQLAPIIdOutput}/v1/queries`; - const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[providerName]); const { isAdminApp, region } = await providerPlugin.isAmplifyAdminApp(appId); if (isAdminApp) { if (region !== Region) { - context.print.warning(`Region mismatch: Amplify service returned '${region}', but found '${Region}' in amplify-meta.json.`); + printer.warn(`Region mismatch: Amplify service returned '${region}', but found '${Region}' in amplify-meta.json.`); } const { envName } = context.amplify.getEnvInfo(); const baseUrl: string = providerPlugin.adminBackendMap[region].amplifyAdminUrl; @@ -190,26 +193,24 @@ export const openConsole = async (context: $TSContext) => { } else if (selectedConsole === codePipeline) { url = `https://${Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${PipelineName}/view`; } else { - context.print.error('Option not available'); + printer.error('Option not available'); return; } } open(url, { wait: false }); } else { - context.print.error('AppSync API is not pushed in the cloud.'); + printer.error('AppSync API is not pushed in the cloud.'); } }; -const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFilename, serviceMetadata) => { +const serviceApiInputWalkthrough = async (context: $TSContext, serviceMetadata) => { let continuePrompt = false; let authConfig; let defaultAuthType; let resolverConfig; const { amplify } = context; const { inputs } = serviceMetadata; - const defaultValuesSrc = `${__dirname}/../default-values/${defaultValuesFilename}`; - const { getAllDefaults } = require(defaultValuesSrc); const allDefaultValues = getAllDefaults(amplify.getProjectDetails()); let resourceAnswers = {}; @@ -336,7 +337,7 @@ const serviceApiInputWalkthrough = async (context: $TSContext, defaultValuesFile }; }; -const updateApiInputWalkthrough = async (context, project, resolverConfig, modelTypes) => { +const updateApiInputWalkthrough = async (context: $TSContext, project: $TSObject, resolverConfig, modelTypes) => { let authConfig; let defaultAuthType; const updateChoices = [ @@ -388,15 +389,16 @@ const updateApiInputWalkthrough = async (context, project, resolverConfig, model }; }; -export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilename, serviceMetadata) => { - const resourceName = resourceAlreadyExists(context); - const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); +export const serviceWalkthrough = async (context: $TSContext, serviceMetadata: $TSObject) => { + const resourceName = resourceAlreadyExists(); + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[providerName]); const transformerVersion = providerPlugin.getTransformerVersion(context); await addLambdaAuthorizerChoice(context); + if (resourceName) { const errMessage = 'You already have an AppSync API in your project. Use the "amplify update api" command to update your existing AppSync API.'; - context.print.warning(errMessage); + printer.warn(errMessage); await context.usageData.emitError(new ResourceAlreadyExistsError(errMessage)); exitOnNextTick(0); } @@ -404,7 +406,7 @@ export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilen const { amplify } = context; const { inputs } = serviceMetadata; - const basicInfoAnswers = await serviceApiInputWalkthrough(context, defaultValuesFilename, serviceMetadata); + const basicInfoAnswers = await serviceApiInputWalkthrough(context, serviceMetadata); let schemaContent = ''; let askToEdit = true; @@ -431,7 +433,7 @@ export const serviceWalkthrough = async (context: $TSContext, defaultValuesFilen }; }; -export const updateWalkthrough = async (context): Promise => { +export const updateWalkthrough = async (context: $TSContext): Promise => { const { allResources } = await context.amplify.getResourceStatus(); let resourceDir; let resourceName; @@ -450,11 +452,10 @@ export const updateWalkthrough = async (context): Promise => { ); } ({ resourceName } = resource); - const backEndDir = context.amplify.pathManager.getBackendDirPath(); - resourceDir = path.normalize(path.join(backEndDir, category, resourceName)); + resourceDir = pathManager.getResourceDirectoryPath(undefined, category, resourceName); } else { const errMessage = 'No AppSync resource to update. Use the "amplify add api" command to update your existing AppSync API.'; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); } @@ -493,7 +494,7 @@ export const updateWalkthrough = async (context): Promise => { }; }; -async function displayApiInformation(context, resource, project) { +async function displayApiInformation(context: $TSContext, resource: $TSObject, project: $TSObject) { let authModes: string[] = []; authModes.push( `- Default: ${await displayAuthMode(context, resource, resource.output.authConfig.defaultAuthentication.authenticationType)}`, @@ -502,35 +503,35 @@ async function displayApiInformation(context, resource, project) { authModes.push(`- ${await displayAuthMode(context, resource, authMode.authenticationType)}`); }); - context.print.info(''); + printer.info(''); - context.print.success('General information'); - context.print.info('- Name: '.concat(resource.resourceName)); + printer.success('General information'); + printer.info('- Name: '.concat(resource.resourceName)); if (resource?.output?.GraphQLAPIEndpointOutput) { - context.print.info(`- API endpoint: ${resource?.output?.GraphQLAPIEndpointOutput}`); + printer.info(`- API endpoint: ${resource?.output?.GraphQLAPIEndpointOutput}`); } - context.print.info(''); + printer.info(''); - context.print.success('Authorization modes'); - authModes.forEach(authMode => context.print.info(authMode)); - context.print.info(''); + printer.success('Authorization modes'); + authModes.forEach(authMode => printer.info(authMode)); + printer.info(''); - context.print.success('Conflict detection (required for DataStore)'); + printer.success('Conflict detection (required for DataStore)'); if (project.config && !_.isEmpty(project.config.ResolverConfig)) { - context.print.info( + printer.info( `- Conflict resolution strategy: ${ conflictResolutionHanlderChoices.find(choice => choice.value === project.config.ResolverConfig.project.ConflictHandler).name }`, ); } else { - context.print.info('- Disabled'); + printer.info('- Disabled'); } - context.print.info(''); + printer.info(''); } -async function displayAuthMode(context, resource, authMode) { - if (authMode == 'API_KEY' && resource.output.GraphQLAPIKeyOutput) { +async function displayAuthMode(context: $TSContext, resource: $TSObject, authMode: string) { + if (authMode === 'API_KEY' && resource.output.GraphQLAPIKeyOutput) { let { apiKeys } = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getAppSyncApiKeys', { apiId: resource.output.GraphQLAPIIdOutput, }); @@ -546,13 +547,13 @@ async function displayAuthMode(context, resource, authMode) { return authProviderChoices.find(choice => choice.value === authMode).name; } -async function askAdditionalQuestions(context, authConfig, defaultAuthType, modelTypes?) { +async function askAdditionalQuestions(context: $TSContext, authConfig, defaultAuthType, modelTypes?) { authConfig = await askAdditionalAuthQuestions(context, authConfig, defaultAuthType); return { authConfig }; } -async function askResolverConflictQuestion(context, resolverConfig, modelTypes?) { - let resolverConfigResponse: any = {}; +async function askResolverConflictQuestion(context: $TSContext, resolverConfig, modelTypes?) { + let resolverConfigResponse: $TSObject = {}; if (await context.prompt.confirm('Enable conflict detection?', !resolverConfig?.project)) { resolverConfigResponse = await askResolverConflictHandlerQuestion(context, modelTypes); @@ -561,8 +562,8 @@ async function askResolverConflictQuestion(context, resolverConfig, modelTypes?) return resolverConfigResponse; } -async function askResolverConflictHandlerQuestion(context, modelTypes?) { - let resolverConfig: any = {}; +async function askResolverConflictHandlerQuestion(context: $TSContext, modelTypes?) { + let resolverConfig: $TSObject = {}; const askConflictResolutionStrategy = async msg => { let conflictResolutionStrategy; @@ -580,13 +581,13 @@ async function askResolverConflictHandlerQuestion(context, modelTypes?) { ({ conflictResolutionStrategy } = await inquirer.prompt([conflictResolutionQuestion])); } while (conflictResolutionStrategy === 'Learn More'); - let syncConfig: any = { + let syncConfig: $TSObject = { ConflictHandler: conflictResolutionStrategy, ConflictDetection: 'VERSION', }; if (conflictResolutionStrategy === 'LAMBDA') { - const { newFunction, lambdaFunctionName } = await askSyncFunctionQuestion(context); + const { newFunction, lambdaFunctionName } = await askSyncFunctionQuestion(); syncConfig.LambdaConflictHandler = { name: lambdaFunctionName, new: newFunction, @@ -613,10 +614,8 @@ async function askResolverConflictHandlerQuestion(context, modelTypes?) { if (selectedModelTypes.length > 0) { resolverConfig.models = {}; - for (let i = 0; i < selectedModelTypes.length; i += 1) { - resolverConfig.models[selectedModelTypes[i]] = await askConflictResolutionStrategy( - `Select the resolution strategy for ${selectedModelTypes[i]} model`, - ); + for (const modelType of selectedModelTypes) { + resolverConfig.models[modelType] = await askConflictResolutionStrategy(`Select the resolution strategy for ${modelType} model`); } } } @@ -625,7 +624,7 @@ async function askResolverConflictHandlerQuestion(context, modelTypes?) { return resolverConfig; } -async function askSyncFunctionQuestion(context) { +async function askSyncFunctionQuestion() { const syncLambdaQuestion = { type: 'list', name: 'syncLambdaAnswer', @@ -660,8 +659,8 @@ async function askSyncFunctionQuestion(context) { return { newFunction, lambdaFunctionName }; } -async function addLambdaAuthorizerChoice(context) { - const providerPlugin = await import(context.amplify.getProviderPlugins(context)[provider]); +async function addLambdaAuthorizerChoice(context: $TSContext) { + const providerPlugin = await import(context.amplify.getProviderPlugins(context)[providerName]); const transformerVersion = providerPlugin.getTransformerVersion(context); if (transformerVersion === 2 && !authProviderChoices.some(choice => choice.value == 'AWS_LAMBDA')) { authProviderChoices.push({ @@ -671,9 +670,9 @@ async function addLambdaAuthorizerChoice(context) { } } -async function askDefaultAuthQuestion(context) { +async function askDefaultAuthQuestion(context: $TSContext) { await addLambdaAuthorizerChoice(context); - const currentAuthConfig = getAppSyncAuthConfig(context.amplify.getProjectMeta()); + const currentAuthConfig = getAppSyncAuthConfig(stateManager.getMeta()); const currentDefaultAuth = currentAuthConfig && currentAuthConfig.defaultAuthentication ? currentAuthConfig.defaultAuthentication.authenticationType : undefined; @@ -698,8 +697,8 @@ async function askDefaultAuthQuestion(context) { }; } -export async function askAdditionalAuthQuestions(context, authConfig, defaultAuthType) { - const currentAuthConfig = getAppSyncAuthConfig(context.amplify.getProjectMeta()); +export async function askAdditionalAuthQuestions(context: $TSContext, authConfig: $TSObject, defaultAuthType) { + const currentAuthConfig = getAppSyncAuthConfig(stateManager.getMeta()); authConfig.additionalAuthenticationProviders = []; if (await context.prompt.confirm('Configure additional auth types?')) { // Get additional auth configured @@ -720,9 +719,7 @@ export async function askAdditionalAuthQuestions(context, authConfig, defaultAut const additionalProvidersAnswer = await inquirer.prompt([additionalProvidersQuestion]); - for (let i = 0; i < additionalProvidersAnswer.authType.length; i += 1) { - const authProvider = additionalProvidersAnswer.authType[i]; - + for (const authProvider of additionalProvidersAnswer.authType) { const config = await askAuthQuestions( authProvider, context, @@ -740,10 +737,10 @@ export async function askAdditionalAuthQuestions(context, authConfig, defaultAut return authConfig; } -export async function askAuthQuestions(authType, context, printLeadText = false, authSettings) { +export async function askAuthQuestions(authType: string, context: $TSContext, printLeadText = false, authSettings) { if (authType === 'AMAZON_COGNITO_USER_POOLS') { if (printLeadText) { - context.print.info('Cognito UserPool configuration'); + printer.info('Cognito UserPool configuration'); } const userPoolConfig = await askUserPoolQuestions(context); @@ -753,7 +750,7 @@ export async function askAuthQuestions(authType, context, printLeadText = false, if (authType === 'API_KEY') { if (printLeadText) { - context.print.info('API key configuration'); + printer.info('API key configuration'); } const apiKeyConfig = await askApiKeyQuestions(authSettings); @@ -769,7 +766,7 @@ export async function askAuthQuestions(authType, context, printLeadText = false, if (authType === 'OPENID_CONNECT') { if (printLeadText) { - context.print.info('OpenID Connect configuration'); + printer.info('OpenID Connect configuration'); } const openIDConnectConfig = await askOpenIDConnectQuestions(authSettings); @@ -788,17 +785,17 @@ export async function askAuthQuestions(authType, context, printLeadText = false, } const errMessage = `Unknown authType: ${authType}`; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new UnknownResourceTypeError(errMessage)); exitOnNextTick(1); } -async function askUserPoolQuestions(context) { - let authResourceName = checkIfAuthExists(context); +async function askUserPoolQuestions(context: $TSContext) { + let authResourceName = checkIfAuthExists(); if (!authResourceName) { authResourceName = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'add', [context, true]); } else { - context.print.info('Use a Cognito user pool configured as a part of this project.'); + printer.info('Use a Cognito user pool configured as a part of this project.'); } // Added resources are prefixed with auth @@ -812,7 +809,7 @@ async function askUserPoolQuestions(context) { }; } -export async function askApiKeyQuestions(authSettings = undefined) { +export async function askApiKeyQuestions(authSettings: $TSObject = undefined) { let defaultValues = { apiKeyExpirationDays: 7, description: undefined, @@ -853,7 +850,7 @@ export async function askApiKeyQuestions(authSettings = undefined) { }; } -async function askOpenIDConnectQuestions(authSettings) { +async function askOpenIDConnectQuestions(authSettings: $TSObject) { let defaultValues = { authTTL: undefined, clientId: undefined, @@ -907,7 +904,7 @@ async function askOpenIDConnectQuestions(authSettings) { }; } -async function validateDays(input) { +async function validateDays(input: string) { const isValid = /^\d{0,3}$/.test(input); const days = isValid ? parseInt(input, 10) : 0; if (!isValid || days < 1 || days > 365) { @@ -917,7 +914,7 @@ async function validateDays(input) { return true; } -function validateIssuerUrl(input) { +function validateIssuerUrl(input: string) { const isValid = /^(((?!http:\/\/(?!localhost))([a-zA-Z0-9.]{1,}):\/\/([a-zA-Z0-9-._~:?#@!$&'()*+,;=/]{1,})\/)|(?!http)(?!https)([a-zA-Z0-9.]{1,}):\/\/)$/.test( input, @@ -930,7 +927,7 @@ function validateIssuerUrl(input) { return true; } -function validateTTL(input) { +function validateTTL(input: string) { const isValid = /^\d+$/.test(input); if (!isValid) { @@ -940,32 +937,32 @@ function validateTTL(input) { return true; } -function resourceAlreadyExists(context) { - const { amplify } = context; - const { amplifyMeta } = amplify.getProjectDetails(); +function resourceAlreadyExists() { + const meta = stateManager.getMeta(); let resourceName; - if (amplifyMeta[category]) { - const categoryResources = amplifyMeta[category]; - Object.keys(categoryResources).forEach(resource => { + if (meta[category]) { + const categoryResources = meta[category]; + for (const resource of Object.keys(categoryResources)) { if (categoryResources[resource].service === serviceName) { resourceName = resource; + break; } - }); + } } return resourceName; } -export const migrate = async context => { +export const migrate = async (context: $TSContext) => { await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', { forceCompile: true, migrate: true, }); }; -export const getIAMPolicies = (resourceName: string, operations: string[], context: any) => { - let policy: any = {}; +export const getIAMPolicies = (resourceName: string, operations: string[]) => { + let policy: $TSObject = {}; const resources = []; const actions = []; if (!FeatureFlags.getBoolean('appSync.generateGraphQLPermissions')) { @@ -985,7 +982,7 @@ export const getIAMPolicies = (resourceName: string, operations: string[], conte actions.push('appsync:Delete*'); break; default: - console.log(`${crudOption} not supported`); + printer.info(`${crudOption} not supported`); } }); resources.push(buildPolicyResource(resourceName, null)); @@ -1001,7 +998,7 @@ export const getIAMPolicies = (resourceName: string, operations: string[], conte }; const attributes = ['GraphQLAPIIdOutput', 'GraphQLAPIEndpointOutput']; - if (authConfigHasApiKey(getAppSyncAuthConfig(context.amplify.getProjectMeta()))) { + if (authConfigHasApiKey(getAppSyncAuthConfig(stateManager.getMeta()))) { attributes.push('GraphQLAPIKeyOutput'); } @@ -1177,7 +1174,7 @@ async function createLambdaAuthorizerFunction(context: $TSContext) { const backendConfigs = { service: FunctionServiceNameLambdaFunction, - providerPlugin: provider, + providerPlugin: providerName, build: true, }; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough.ts index 9b38fb744f7..5ba0df021bf 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough.ts @@ -1,8 +1,10 @@ -import { exitOnNextTick, ResourceDoesNotExistError } from 'amplify-cli-core'; +import { $TSAny, $TSContext, $TSObject, exitOnNextTick, ResourceDoesNotExistError } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; import inquirer from 'inquirer'; import { category } from '../../../category-constants'; import { DEPLOYMENT_MECHANISM } from '../base-api-stack'; import { GitHubSourceActionInfo } from '../pipeline-with-awaiter'; +import { getAllDefaults } from '../default-values/containers-defaults'; const serviceName = 'ElasticContainer'; @@ -44,11 +46,8 @@ export type ServiceConfiguration = { gitHubInfo?: GitHubSourceActionInfo; }; -export async function serviceWalkthrough(context, defaultValuesFilename, apiType: API_TYPE): Promise> { - const { amplify } = context; - const defaultValuesSrc = `${__dirname}/../default-values/${defaultValuesFilename}`; - const { getAllDefaults } = await import(defaultValuesSrc); - const allDefaultValues = getAllDefaults(amplify.getProjectDetails()); +export async function serviceWalkthrough(context: $TSContext, apiType: API_TYPE): Promise> { + const allDefaultValues = getAllDefaults(); const resourceName = await askResourceName(context, allDefaultValues); @@ -57,7 +56,7 @@ export async function serviceWalkthrough(context, defaultValuesFilename, apiType return { resourceName, ...containerInfo }; } -async function askResourceName(context, allDefaultValues) { +async function askResourceName(context: $TSContext, allDefaultValues: $TSObject) { const { amplify } = context; const { resourceName } = await inquirer.prompt([ @@ -80,7 +79,7 @@ async function askResourceName(context, allDefaultValues) { return resourceName; } -async function askContainerSource(context, resourceName: string, apiType: API_TYPE): Promise> { +async function askContainerSource(context: $TSContext, resourceName: string, apiType: API_TYPE): Promise> { return newContainer(context, resourceName, apiType); } @@ -89,7 +88,7 @@ export enum IMAGE_SOURCE_TYPE { CUSTOM = 'CUSTOM', } -async function newContainer(context, resourceName: string, apiType: API_TYPE): Promise> { +async function newContainer(context: $TSContext, resourceName: string, apiType: API_TYPE): Promise> { let imageSource: { type: IMAGE_SOURCE_TYPE; template?: string }; let choices = []; @@ -171,8 +170,8 @@ async function newContainer(context, resourceName: string, apiType: API_TYPE): P let gitHubToken: string; if (deploymentMechanismQuestion.deploymentMechanism === DEPLOYMENT_MECHANISM.INDENPENDENTLY_MANAGED) { - context.print.info('We need a Github Personal Access Token to automatically build & deploy your Fargate task on every Github commit.'); - context.print.info( + printer.info('We need a Github Personal Access Token to automatically build & deploy your Fargate task on every Github commit.'); + printer.info( 'Learn more about Github Personal Access Token here: https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token', ); @@ -234,7 +233,7 @@ async function newContainer(context, resourceName: string, apiType: API_TYPE): P }; } -export async function updateWalkthrough(context, defaultValuesFilename, apiType: API_TYPE) { +export async function updateWalkthrough(context: $TSContext, apiType: API_TYPE) { const { allResources } = await context.amplify.getResourceStatus(); const resources = allResources @@ -247,7 +246,7 @@ export async function updateWalkthrough(context, defaultValuesFilename, apiType: // There can only be one appsync resource if (resources.length === 0) { const errMessage = `No ${apiType} API resource to update. Use "amplify add api" command to create a new ${apiType} API`; - context.print.error(errMessage); + printer.error(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); return; @@ -302,7 +301,7 @@ export async function updateWalkthrough(context, defaultValuesFilename, apiType: const hasAccessableResources = ['storage', 'function'].some(categoryName => { return Object.keys(meta[categoryName] ?? {}).length > 0; }); - let rolePermissions: any = {}; + let rolePermissions: $TSAny = {}; if ( hasAccessableResources && (await context.amplify.confirmPrompt('Do you want to access other resources in this project from your api?')) diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/amplify-meta-utils.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/amplify-meta-utils.ts index d920a8d9c96..e3385279de3 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/amplify-meta-utils.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/amplify-meta-utils.ts @@ -1,6 +1,7 @@ +import { $TSAny, $TSMeta, $TSObject, AmplifyCategories, AmplifySupportedService, stateManager } from 'amplify-cli-core'; import _ from 'lodash'; -export const authConfigHasApiKey = authConfig => { +export const authConfigHasApiKey = (authConfig?: $TSAny) => { if (!authConfig) { return false; } @@ -13,12 +14,11 @@ export const authConfigHasApiKey = authConfig => { ); }; -export const checkIfAuthExists = context => { - const { amplify } = context; - const { amplifyMeta } = amplify.getProjectDetails(); +export const checkIfAuthExists = () => { + const amplifyMeta = stateManager.getMeta(); let authResourceName; - const authServiceName = 'Cognito'; - const authCategoryName = 'auth'; + const authServiceName = AmplifySupportedService.COGNITO; + const authCategoryName = AmplifyCategories.AUTH; if (amplifyMeta[authCategoryName] && Object.keys(amplifyMeta[authCategoryName]).length > 0) { const categoryResources = amplifyMeta[authCategoryName]; @@ -34,15 +34,15 @@ export const checkIfAuthExists = context => { // some utility functions to extract the AppSync API name and config from amplify-meta -export const getAppSyncAuthConfig = projectMeta => { +export const getAppSyncAuthConfig = (projectMeta: $TSMeta) => { const entry = getAppSyncAmplifyMetaEntry(projectMeta); if (entry) { - const value = entry[1] as any; + const value = entry[1] as $TSAny; return value && value.output ? value.output.authConfig : {}; } }; -export const getAppSyncResourceName = (projectMeta: any): string | undefined => { +export const getAppSyncResourceName = (projectMeta: $TSMeta): string | undefined => { const entry = getAppSyncAmplifyMetaEntry(projectMeta); if (entry) { return entry[0]; @@ -51,6 +51,8 @@ export const getAppSyncResourceName = (projectMeta: any): string | undefined => // project meta is the contents of amplify-meta.json // typically retreived using context.amplify.getProjectMeta() -const getAppSyncAmplifyMetaEntry = (projectMeta: any) => { - return Object.entries(projectMeta.api || {}).find(([, value]) => (value as any).service === 'AppSync'); +const getAppSyncAmplifyMetaEntry = (projectMeta: $TSMeta) => { + return Object.entries(projectMeta[AmplifyCategories.API] || {}).find( + ([, value]) => (value as $TSObject).service === AmplifySupportedService.APPSYNC, + ); }; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/containers-artifacts.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/containers-artifacts.ts index 51da9da2da7..5a1368cfa8d 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/containers-artifacts.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/containers-artifacts.ts @@ -2,14 +2,14 @@ import { Octokit } from '@octokit/rest'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; import * as path from 'path'; -import uuid from 'uuid'; +import { v4 as uuid } from 'uuid'; import { provider as cloudformationProviderName } from '../../../provider-utils/awscloudformation/aws-constants'; import { getContainers } from '../../../provider-utils/awscloudformation/docker-compose'; import Container from '../docker-compose/ecs-objects/container'; import { EcsStack } from '../ecs-apigw-stack'; import { API_TYPE, ResourceDependency } from '../../../provider-utils/awscloudformation/service-walkthroughs/containers-walkthrough'; import { getGitHubOwnerRepoFromPath } from '../../../provider-utils/awscloudformation/utils/github'; -import { JSONUtilities, pathManager, readCFNTemplate } from 'amplify-cli-core'; +import { $TSAny, $TSContext, JSONUtilities, pathManager, readCFNTemplate } from 'amplify-cli-core'; import { DEPLOYMENT_MECHANISM } from '../base-api-stack'; import { setExistingSecretArns } from './containers/set-existing-secret-arns'; import { category } from '../../../category-constants'; @@ -28,9 +28,9 @@ export type ApiResource = { restrictAccess: boolean; dependsOn: ResourceDependency[]; environmentMap: Record; - categoryPolicies: any[]; - mutableParametersState: any; - output?: Record; + categoryPolicies: $TSAny[]; + mutableParametersState: $TSAny; + output?: Record; apiType?: API_TYPE; exposedContainer?: { name: string; port: number }; }; @@ -46,7 +46,7 @@ type ContainerArtifactsMetadata = { }; export async function generateContainersArtifacts( - context: any, + context: $TSContext, resource: ApiResource, askForExposedContainer: boolean = false, ): Promise { @@ -116,7 +116,12 @@ export async function generateContainersArtifacts( }; } -export async function processDockerConfig(context: any, resource: ApiResource, srcPath: string, askForExposedContainer: boolean = false) { +export async function processDockerConfig( + context: $TSContext, + resource: ApiResource, + srcPath: string, + askForExposedContainer: boolean = false, +) { const { providers: { [cloudformationProviderName]: provider }, } = context.amplify.getProjectMeta(); @@ -306,7 +311,7 @@ export async function processDockerConfig(context: any, resource: ApiResource, s }; } -async function shouldUpdateSecrets(context: any, secrets: Record): Promise { +async function shouldUpdateSecrets(context: $TSContext, secrets: Record): Promise { const hasSecrets = Object.keys(secrets).length > 0; if (!hasSecrets || context.exeInfo.inputParams.yes) { diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/dynamic-imports.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/dynamic-imports.ts index 05b3688da0d..2c7adced535 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/dynamic-imports.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/dynamic-imports.ts @@ -1,4 +1,10 @@ -export const serviceMetadataFor = async service => (await import('../../supported-services')).supportedServices[service]; -export const datasourceMetadataFor = async datasource => (await import('../../supported-datasources')).supportedDatasources[datasource]; -export const getServiceWalkthrough = async walkthroughFilename => - (await import(`../service-walkthroughs/${walkthroughFilename}`)).serviceWalkthrough; +import * as path from 'path'; + +export const serviceMetadataFor = async (service: string) => + (await import(path.join('..', '..', 'supported-services'))).supportedServices[service]; + +export const datasourceMetadataFor = async (datasource: string) => + (await import(path.join('..', '..', 'supported-datasources'))).supportedDatasources[datasource]; + +export const getServiceWalkthrough = async (walkthroughFilename: string) => + (await import(path.join('..', 'service-walkthroughs', walkthroughFilename))).serviceWalkthrough; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/edit-schema-flow.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/edit-schema-flow.ts index 11964238731..3d339aecd0b 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/edit-schema-flow.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/edit-schema-flow.ts @@ -1,18 +1,14 @@ -import inquirer, { ConfirmQuestion } from 'inquirer'; -import path from 'path'; +import { $TSContext, pathManager } from 'amplify-cli-core'; +import { prompter } from 'amplify-prompts'; +import * as path from 'path'; import { category } from '../../../category-constants'; import { gqlSchemaFilename } from '../aws-constants'; -export const editSchemaFlow = async (context: any, apiName: string) => { - const prompt: ConfirmQuestion = { - type: 'confirm', - name: 'editNow', - message: 'Do you want to edit the schema now?', - default: true, - }; +export const editSchemaFlow = async (context: $TSContext, apiName: string) => { + if (!(await prompter.yesOrNo('Do you want to edit the schema now?', true))) { + return; + } - if (!(await inquirer.prompt(prompt)).editNow) return; - - const schemaPath = path.join(context.amplify.pathManager.getBackendDirPath(), category, apiName, gqlSchemaFilename); + const schemaPath = path.join(pathManager.getResourceDirectoryPath(undefined, category, apiName), gqlSchemaFilename); await context.amplify.openEditor(context, schemaPath, false); }; diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/print-api-key-warnings.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/print-api-key-warnings.ts index f98601878b2..14eeb6a9339 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/print-api-key-warnings.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/print-api-key-warnings.ts @@ -1,16 +1,18 @@ +import { printer } from 'amplify-prompts'; + // If adding or removing the API_KEY auth type, print a warning that resources that depend on the API must re-add the API as a dependency to have the API key parameter added / removed. -export const printApiKeyWarnings = (context, oldConfigHadApiKey: boolean, newConfigHasApiKey: boolean) => { +export const printApiKeyWarnings = (oldConfigHadApiKey: boolean, newConfigHasApiKey: boolean) => { if (oldConfigHadApiKey && !newConfigHasApiKey) { - context.print.warning('The API_KEY auth type has been removed from the API.'); - context.print.warning( + printer.warn('The API_KEY auth type has been removed from the API.'); + printer.warn( 'If other resources depend on this API, run "amplify update " and reselect this API to remove the dependency on the API key.', ); - context.print.warning('⚠️ This must be done before running "amplify push" to prevent a push failure'); + printer.warn('⚠️ This must be done before running "amplify push" to prevent a push failure'); } if (!oldConfigHadApiKey && newConfigHasApiKey) { - context.print.warning('The API_KEY auth type has been added to the API.'); - context.print.warning( + printer.warn('The API_KEY auth type has been added to the API.'); + printer.warn( '⚠️ If other resources depend on this API and need access to the API key, run "amplify update " and reselect this API as a dependency to add the API key dependency.', ); } diff --git a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/rest-api-path-utils.ts b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/rest-api-path-utils.ts index 37370c2deac..2893bfe9a98 100644 --- a/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/rest-api-path-utils.ts +++ b/packages/amplify-category-api/src/provider-utils/awscloudformation/utils/rest-api-path-utils.ts @@ -37,7 +37,7 @@ export const validatePathName = (name: string) => { // } // // checkForPathOverlap assumes that all provided paths have previously been run through validatePathName(). -export const checkForPathOverlap = (name: string, paths: { name: string }[]) => { +export const checkForPathOverlap = (name: string, paths: string[]) => { // Split name into an array of its components. const split = name.split('/').filter(sub => sub !== ''); // Because name starts with a /, this filters out the first empty element @@ -59,7 +59,7 @@ export const checkForPathOverlap = (name: string, paths: { name: string }[]) => subpath = `${subpath}/${sub}`; // Explicitly check for the path / since it overlaps with any other valid path. // If the path isn't /, replace all of its parameters with '{}' when checking for overlap in find(). - overlappingPath = paths.map(path => path.name).find(name => name === '/' || name.replace(/{[a-zA-Z0-9\-]+}/g, '{}') === subpath); + overlappingPath = paths.find(name => name === '/' || name.replace(/{[a-zA-Z0-9\-]+}/g, '{}') === subpath); return overlappingPath !== undefined; }); if (subMatch) { diff --git a/packages/amplify-category-api/src/provider-utils/supported-datasources.ts b/packages/amplify-category-api/src/provider-utils/supported-datasources.ts index 65b5b019106..b0e2a08bf69 100644 --- a/packages/amplify-category-api/src/provider-utils/supported-datasources.ts +++ b/packages/amplify-category-api/src/provider-utils/supported-datasources.ts @@ -27,7 +27,6 @@ export const supportedDatasources = { }, ], alias: 'Aurora Serverless', - defaultValuesFilename: 'appSync-rds-defaults.js', serviceWalkthroughFilename: 'appSync-rds-walkthrough.js', cfnFilename: 'appSync-rds-cloudformation-template-default.yml.ejs', provider: 'awscloudformation', diff --git a/packages/amplify-category-api/src/provider-utils/supported-services.ts b/packages/amplify-category-api/src/provider-utils/supported-services.ts index 99cc2e128bc..70734fee701 100644 --- a/packages/amplify-category-api/src/provider-utils/supported-services.ts +++ b/packages/amplify-category-api/src/provider-utils/supported-services.ts @@ -122,7 +122,6 @@ export const supportedServices = { }, ], alias: 'GraphQL', - defaultValuesFilename: 'appSync-defaults.js', serviceWalkthroughFilename: 'appSync-walkthrough.js', cfnFilename: 'appSync-cloudformation-template-default.yml.ejs', provider: 'awscloudformation', @@ -148,9 +147,7 @@ export const supportedServices = { }, ], alias: 'REST', - defaultValuesFilename: 'apigw-defaults.js', serviceWalkthroughFilename: 'apigw-walkthrough.js', - cfnFilename: 'apigw-cloudformation-template-default.json.ejs', provider: 'awscloudformation', }, }; diff --git a/packages/amplify-category-api/tsconfig.json b/packages/amplify-category-api/tsconfig.json index 0fc8b8735ce..1d2c1d6a744 100644 --- a/packages/amplify-category-api/tsconfig.json +++ b/packages/amplify-category-api/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "lib", "rootDir": "src", "strict": false, // because package has been converted from js - "allowJs": true + "allowJs": false }, "exclude": [ "coverage", @@ -12,13 +12,15 @@ "resources/awscloudformation/lambdas", "resources/awscloudformation/container-templates", "resources/awscloudformation/graphql-lambda-authorizer", + "resources/awscloudformation/overrides-resource", + "scripts", "src/__tests__" ], "references": [ - {"path": "../amplify-cli-core"}, - {"path": "../amplify-headless-interface"}, - {"path": "../amplify-prompts"}, - {"path": "../graphql-transformer-core"}, - {"path": "../amplify-util-headless-input"}, + { "path": "../amplify-cli-core" }, + { "path": "../amplify-headless-interface" }, + { "path": "../amplify-prompts" }, + { "path": "../amplify-util-headless-input" }, + { "path": "../graphql-transformer-core" } ] } diff --git a/packages/amplify-category-auth/resources/adminAuth/admin-queries-api-params.json b/packages/amplify-category-auth/resources/adminAuth/admin-queries-api-params.json deleted file mode 100644 index 7116196989c..00000000000 --- a/packages/amplify-category-auth/resources/adminAuth/admin-queries-api-params.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "authRoleName": { - "Ref": "AuthRoleName" - }, - "unauthRoleName": { - "Ref": "UnauthRoleName" - } -} diff --git a/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/auth-inputs-manager/auth-input-state.test.ts b/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/auth-inputs-manager/auth-input-state.test.ts index 7004caa3338..c9b63e0925e 100644 --- a/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/auth-inputs-manager/auth-input-state.test.ts +++ b/packages/amplify-category-auth/src/__tests__/provider-utils/awscloudformation/auth-inputs-manager/auth-input-state.test.ts @@ -93,6 +93,7 @@ jest.mock('amplify-cli-core', () => ({ }, }) .mockReturnValueOnce({}), + parse: JSON.parse, }, })); diff --git a/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts b/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts index d7cadd6ac02..39d70d5dbcf 100644 --- a/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts +++ b/packages/amplify-category-auth/src/provider-utils/awscloudformation/handlers/resource-handlers.ts @@ -67,7 +67,7 @@ export const getAddAuthHandler = // cdk transformation in this function // start auth transform here await generateAuthStackTemplate(context, cognitoCLIInputs.cognitoConfig.resourceName); - // remoe this when api and functions transform are done + // remove this when api and functions transform are done await getResourceSynthesizer(context, requestWithDefaults); getPostAddAuthMetaUpdater(context, { service: cognitoCLIInputs.cognitoConfig.serviceName, providerName: provider })( diff --git a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/synthesize-resources.ts b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/synthesize-resources.ts index 1ae21e59679..be14df59abf 100644 --- a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/synthesize-resources.ts +++ b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/synthesize-resources.ts @@ -1,15 +1,22 @@ -import { AuthTriggerConfig, AuthTriggerConnection } from '../service-walkthrough-types/cognito-user-input-types'; +import { + $TSAny, + $TSContext, + $TSObject, + AmplifyCategories, + AmplifySupportedService, + FeatureFlags, + JSONUtilities, + pathManager, +} from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import { copySync, ensureDirSync, existsSync } from 'fs-extra'; +import { get } from 'lodash'; import * as path from 'path'; -import { existsSync, copySync, outputFileSync } from 'fs-extra'; import uuid from 'uuid'; -import { cfnTemplateRoot, privateKeys, adminAuthAssetRoot, triggerRoot } from '../constants'; -import { pathManager, JSONUtilities, FeatureFlags, $TSAny } from 'amplify-cli-core'; -import { get } from 'lodash'; -import { generateUserPoolGroupStackTemplate } from './generate-user-pool-group-stack-template'; +import { adminAuthAssetRoot, cfnTemplateRoot, privateKeys, triggerRoot } from '../constants'; import { CognitoConfiguration } from '../service-walkthrough-types/awsCognito-user-input-types'; - -// keep in sync with ServiceName in amplify-category-function, but probably it will not change -const FunctionServiceNameLambdaFunction = 'Lambda'; +import { AuthTriggerConfig, AuthTriggerConnection } from '../service-walkthrough-types/cognito-user-input-types'; +import { generateUserPoolGroupStackTemplate } from './generate-user-pool-group-stack-template'; /** * Factory function that returns a function that synthesizes all resources based on a CognitoCLIInputs request. @@ -18,7 +25,7 @@ const FunctionServiceNameLambdaFunction = 'Lambda'; * @param cfnFilename The template CFN filename * @param provider The cloud provider name */ -export const getResourceSynthesizer = async (context: any, request: Readonly) => { +export const getResourceSynthesizer = async (context: $TSContext, request: Readonly) => { await lambdaTriggers(request, context, null); // transformation handled in api and functions. await addAdminAuth(context, request.resourceName!, 'add', request.adminQueryGroup); @@ -36,11 +43,11 @@ export const getResourceSynthesizer = async (context: any, request: Readonly) => { +export const getResourceUpdater = async (context: $TSContext, request: Readonly) => { const resources = context.amplify.getProjectMeta(); const adminQueriesFunctionName = get<{ category: string; resourceName: string }[]>(resources, ['api', 'AdminQueries', 'dependsOn'], []) - .filter(resource => resource.category === 'function') + .filter(resource => resource.category === AmplifyCategories.FUNCTION) .map(resource => resource.resourceName) .find(resourceName => resourceName.includes('AdminQueries')); if (adminQueriesFunctionName) { @@ -50,7 +57,9 @@ export const getResourceUpdater = async (context: $TSAny, request: Readonly(context.updatingAuth.triggers) + : context.updatingAuth.triggers; await lambdaTriggers(request, context, previouslySaved); await copyS3Assets(request); @@ -60,7 +69,7 @@ export const getResourceUpdater = async (context: $TSAny, request: Readonly { +export const copyCfnTemplate = async (context: $TSContext, category: string, options: $TSObject, cfnFilename: string) => { const targetDir = path.join(pathManager.getBackendDirPath(), category, options.resourceName); // enable feature flag to remove trigger dependency from auth template @@ -80,12 +89,12 @@ export const copyCfnTemplate = async (context: any, category: string, options: a }; export const saveResourceParameters = ( - context: any, + context: $TSContext, providerName: string, category: string, resource: string, - params: any, - envSpecificParams: any[] = [], + params: $TSObject, + envSpecificParams: $TSAny[] = [], ) => { const provider = context.amplify.getPluginInstance(context, providerName); let privateParams = Object.assign({}, params); @@ -94,7 +103,7 @@ export const saveResourceParameters = ( provider.saveResourceParameters(context, category, resource, privateParams, envSpecificParams); }; -export const removeDeprecatedProps = (props: any) => { +export const removeDeprecatedProps = (props: $TSObject) => { [ 'authRoleName', 'unauthRoleName', @@ -123,12 +132,12 @@ export const removeDeprecatedProps = (props: any) => { return props; }; -const lambdaTriggers = async (coreAnswers: any, context: any, previouslySaved: any) => { - const { handleTriggers } = require('./trigger-flow-auth-helper'); +const lambdaTriggers = async (coreAnswers: $TSObject, context: $TSContext, previouslySaved: $TSAny) => { + const { handleTriggers } = await import('./trigger-flow-auth-helper'); let triggerKeyValues = {}; let authTriggerConnections: AuthTriggerConnection[]; if (coreAnswers.triggers) { - const triggerConfig: AuthTriggerConfig = await handleTriggers(context, coreAnswers, previouslySaved); + const triggerConfig = (await handleTriggers(context, coreAnswers, previouslySaved)) as AuthTriggerConfig; triggerKeyValues = triggerConfig.triggers; authTriggerConnections = triggerConfig.authTriggerConnections; coreAnswers.triggers = triggerKeyValues ? JSONUtilities.stringify(triggerKeyValues) : '{}'; @@ -146,7 +155,12 @@ const lambdaTriggers = async (coreAnswers: any, context: any, previouslySaved: a } // determine permissions needed for each trigger module - coreAnswers.permissions = await context.amplify.getTriggerPermissions(context, coreAnswers.triggers, 'auth', coreAnswers.resourceName); + coreAnswers.permissions = await context.amplify.getTriggerPermissions( + context, + coreAnswers.triggers, + AmplifyCategories.AUTH, + coreAnswers.resourceName, + ); } else if (previouslySaved) { const targetDir = pathManager.getBackendDirPath(); Object.keys(previouslySaved).forEach(p => { @@ -164,20 +178,31 @@ const lambdaTriggers = async (coreAnswers: any, context: any, previouslySaved: a coreAnswers.dependsOn = context.amplify.dependsOnBlock(context, dependsOnKeys, 'Cognito'); }; -export const createUserPoolGroups = async (context: any, resourceName: string, userPoolGroupList?: string[]) => { +export const createUserPoolGroups = async (context: $TSContext, resourceName: string, userPoolGroupList?: string[]) => { if (userPoolGroupList && userPoolGroupList.length > 0) { const userPoolGroupPrecedenceList = []; - for (let i = 0; i < userPoolGroupList.length; i += 1) { + for (let i = 0; i < userPoolGroupList.length; ++i) { userPoolGroupPrecedenceList.push({ groupName: userPoolGroupList[i], precedence: i + 1, }); } - const userPoolGroupFile = path.join(pathManager.getBackendDirPath(), 'auth', 'userPoolGroups', 'user-pool-group-precedence.json'); + const userPoolGroupFile = path.join( + pathManager.getBackendDirPath(), + AmplifyCategories.AUTH, + 'userPoolGroups', + 'user-pool-group-precedence.json', + ); - const userPoolGroupParams = path.join(pathManager.getBackendDirPath(), 'auth', 'userPoolGroups', 'build', 'parameters.json'); + const userPoolGroupParams = path.join( + pathManager.getBackendDirPath(), + AmplifyCategories.AUTH, + 'userPoolGroups', + 'build', + 'parameters.json', + ); /* eslint-disable */ const groupParams = { @@ -193,12 +218,12 @@ export const createUserPoolGroups = async (context: any, resourceName: string, u JSONUtilities.writeJson(userPoolGroupParams, groupParams); JSONUtilities.writeJson(userPoolGroupFile, userPoolGroupPrecedenceList); - context.amplify.updateamplifyMetaAfterResourceAdd('auth', 'userPoolGroups', { + context.amplify.updateamplifyMetaAfterResourceAdd(AmplifyCategories.AUTH, 'userPoolGroups', { service: 'Cognito-UserPool-Groups', providerPlugin: 'awscloudformation', dependsOn: [ { - category: 'auth', + category: AmplifyCategories.AUTH, resourceName, attributes: ['UserPoolId', 'AppClientIDWeb', 'AppClientID', 'IdentityPoolId'], }, @@ -209,32 +234,27 @@ export const createUserPoolGroups = async (context: any, resourceName: string, u } }; -export const updateUserPoolGroups = async (context: any, resourceName: string, userPoolGroupList?: string[]) => { +export const updateUserPoolGroups = async (context: $TSContext, resourceName: string, userPoolGroupList?: string[]) => { if (userPoolGroupList && userPoolGroupList.length > 0) { const userPoolGroupPrecedenceList = []; - for (let i = 0; i < userPoolGroupList.length; i += 1) { + for (let i = 0; i < userPoolGroupList.length; ++i) { userPoolGroupPrecedenceList.push({ groupName: userPoolGroupList[i], precedence: i + 1, }); } - const userPoolGroupFile = path.join( - context.amplify.pathManager.getBackendDirPath(), - 'auth', - 'userPoolGroups', - 'user-pool-group-precedence.json', - ); - - outputFileSync(userPoolGroupFile, JSON.stringify(userPoolGroupPrecedenceList, null, 4)); + const userPoolGroupFolder = path.join(pathManager.getBackendDirPath(), AmplifyCategories.AUTH, 'userPoolGroups'); + ensureDirSync(userPoolGroupFolder); + JSONUtilities.writeJson(path.join(userPoolGroupFolder, 'user-pool-group-precedence.json'), userPoolGroupPrecedenceList); - context.amplify.updateamplifyMetaAfterResourceUpdate('auth', 'userPoolGroups', { + context.amplify.updateamplifyMetaAfterResourceUpdate(AmplifyCategories.AUTH, 'userPoolGroups', 'userPoolGroups', { service: 'Cognito-UserPool-Groups', providerPlugin: 'awscloudformation', dependsOn: [ { - category: 'auth', + category: AmplifyCategories.AUTH, resourceName, attributes: ['UserPoolId', 'AppClientIDWeb', 'AppClientID', 'IdentityPoolId'], }, @@ -246,7 +266,7 @@ export const updateUserPoolGroups = async (context: any, resourceName: string, u }; const addAdminAuth = async ( - context: any, + context: $TSContext, authResourceName: string, operation: 'update' | 'add', adminGroup?: string, @@ -263,19 +283,19 @@ const addAdminAuth = async ( }; const createAdminAuthFunction = async ( - context: any, + context: $TSContext, authResourceName: string, functionName: string, adminGroup: string, operation: 'update' | 'add', ) => { - const targetDir = path.join(pathManager.getBackendDirPath(), 'function', functionName); + const targetDir = path.join(pathManager.getBackendDirPath(), AmplifyCategories.FUNCTION, functionName); let lambdaGroupVar = adminGroup; const dependsOn = []; dependsOn.push({ - category: 'auth', + category: AmplifyCategories.AUTH, resourceName: authResourceName, attributes: ['UserPoolId'], }); @@ -326,78 +346,53 @@ const createAdminAuthFunction = async ( if (operation === 'add') { // add amplify-meta and backend-config const backendConfigs = { - service: FunctionServiceNameLambdaFunction, + service: AmplifySupportedService.LAMBDA, providerPlugin: 'awscloudformation', build: true, dependsOn, }; - await context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); - context.print.success(`Successfully added ${functionName} function locally`); + await context.amplify.updateamplifyMetaAfterResourceAdd(AmplifyCategories.FUNCTION, functionName, backendConfigs); + printer.success(`Successfully added ${functionName} function locally`); } else { - context.print.success(`Successfully updated ${functionName} function locally`); + printer.success(`Successfully updated ${functionName} function locally`); } }; -const createAdminAuthAPI = async (context: any, authResourceName: string, functionName: string, operation: 'update' | 'add') => { +const createAdminAuthAPI = async (context: $TSContext, authResourceName: string, functionName: string, operation: 'update' | 'add') => { const apiName = 'AdminQueries'; - const targetDir = path.join(pathManager.getBackendDirPath(), 'api', apiName); - const dependsOn = []; - - dependsOn.push( + const dependsOn = [ { - category: 'auth', + category: AmplifyCategories.AUTH, resourceName: authResourceName, attributes: ['UserPoolId'], }, { - category: 'function', + category: AmplifyCategories.FUNCTION, resourceName: functionName, attributes: ['Arn', 'Name'], }, - ); + ]; const apiProps = { + apiName, functionName, authResourceName, dependsOn, }; - const copyJobs = [ - { - dir: adminAuthAssetRoot, - template: 'admin-queries-api-template.json.ejs', - target: path.join(targetDir, 'admin-queries-cloudformation-template.json'), - }, - { - dir: adminAuthAssetRoot, - template: 'admin-queries-api-params.json', - target: path.join(targetDir, 'parameters.json'), - }, - ]; - - // copy over the files - await context.amplify.copyBatch(context, copyJobs, apiProps, true); - if (operation === 'add') { - // Update amplify-meta and backend-config - const backendConfigs = { - service: 'API Gateway', - providerPlugin: 'awscloudformation', - authorizationType: 'AMAZON_COGNITO_USER_POOLS', - dependsOn, - }; - - await context.amplify.updateamplifyMetaAfterResourceAdd('api', apiName, backendConfigs); - context.print.success(`Successfully added ${apiName} API locally`); + await context.amplify.invokePluginMethod(context, AmplifyCategories.API, undefined, 'addAdminQueriesApi', [context, apiProps]); + printer.success(`Successfully added ${apiName} API locally`); } else { - context.print.success(`Successfully updated ${apiName} API locally`); + await context.amplify.invokePluginMethod(context, AmplifyCategories.API, undefined, 'updateAdminQueriesApi', [context, apiProps]); + printer.success(`Successfully updated ${apiName} API locally`); } }; const copyS3Assets = async (request: CognitoConfiguration) => { - const targetDir = path.join(pathManager.getBackendDirPath(), 'auth', request.resourceName!, 'assets'); - const triggers = request.triggers ? JSONUtilities.parse(request.triggers) : null; + const targetDir = path.join(pathManager.getBackendDirPath(), AmplifyCategories.AUTH, request.resourceName!, 'assets'); + const triggers = request.triggers ? JSONUtilities.parse<$TSAny>(request.triggers) : null; const confirmationFileNeeded = request.triggers && triggers.CustomMessage && triggers.CustomMessage.includes('verification-link'); if (confirmationFileNeeded) { if (!existsSync(targetDir)) { diff --git a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/transform-user-pool-group.js b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/transform-user-pool-group.js index d2712bde9e2..a2cc363ab4e 100644 --- a/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/transform-user-pool-group.js +++ b/packages/amplify-category-auth/src/provider-utils/awscloudformation/utils/transform-user-pool-group.js @@ -1,15 +1,10 @@ +const { JSONUtilities, pathManager } = require('amplify-cli-core'); const path = require('path'); -const fs = require('fs'); const { generateUserPoolGroupStackTemplate } = require('./generate-user-pool-group-stack-template'); const { AuthInputState } = require('../auth-inputs-manager/auth-input-state'); async function transformUserPoolGroupSchema(context) { - const resourceDirPath = path.join( - context.amplify.pathManager.getBackendDirPath(), - 'auth', - 'userPoolGroups', - 'user-pool-group-precedence.json', - ); + const userPoolPrecedencePath = path.join(pathManager.getBackendDirPath(), 'auth', 'userPoolGroups', 'user-pool-group-precedence.json'); const { allResources } = await context.amplify.getResourceStatus(); const authResource = allResources.filter(resource => resource.service === 'Cognito'); @@ -22,7 +17,7 @@ async function transformUserPoolGroupSchema(context) { throw new Error('Cognito UserPool does not exists'); } - const groups = context.amplify.readJsonFile(resourceDirPath); + const groups = JSONUtilities.readJson(userPoolPrecedencePath); // Replace env vars with subs diff --git a/packages/amplify-category-auth/tsconfig.json b/packages/amplify-category-auth/tsconfig.json index eff2879c722..5ba92ce0dfa 100644 --- a/packages/amplify-category-auth/tsconfig.json +++ b/packages/amplify-category-auth/tsconfig.json @@ -13,12 +13,13 @@ "resources", "scripts", "provider-utils/awscloudformation/triggers", - "src/__tests__", + "src/__tests__" ], "references": [ - {"path": "../amplify-cli-core"}, - {"path": "../amplify-headless-interface"}, - {"path": "../amplify-util-headless-input"}, - {"path": "../amplify-util-import"}, + { "path": "../amplify-cli-core" }, + { "path": "../amplify-headless-interface" }, + { "path": "../amplify-prompts" }, + { "path": "../amplify-util-headless-input" }, + { "path": "../amplify-util-import" } ] } diff --git a/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap index 0765b9ff796..3c4925b7167 100644 --- a/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap +++ b/packages/amplify-cli-core/src/__tests__/__snapshots__/cliViewAPI.test.ts.snap @@ -6,22 +6,22 @@ NAME amplify status -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) SYNOPSIS -amplify status [-v|--verbose] [category ...] +amplify status [-v|--verbose] [category ...] DESCRIPTION The amplify status command displays the difference between the deployed state and the local state of the application. -The following options are available: +The following options are available: [category ...] : (Summary mode) Displays the summary of local state vs deployed state of the application usage: #> amplify status #> amplify status api storage --v [category ...] : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. +-v [category ...] : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. If no category is provided, it shows the diff for all categories. usage: #> amplify status -v #> amplify status -v api storage - - " + + " `; diff --git a/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts index adb641e1ecb..e47dc456f29 100644 --- a/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts +++ b/packages/amplify-cli-core/src/__tests__/cliViewAPI.test.ts @@ -3,63 +3,60 @@ import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; describe('CLI View tests', () => { - test('Verbose mode CLI status with category list should correctly initialize ViewResourceTableParams [Non-Help]', () => { - const cliParams : CLIParams = { - cliCommand: 'status', - cliSubcommands: undefined, - cliOptions: { - storage: true, - api: true, - verbose: true, - yes: false - } - } - const view = new ViewResourceTableParams(cliParams); - expect( view.command ).toBe("status"); - expect( view.categoryList).toStrictEqual(['storage', 'api']); - expect( view.help ).toBe(false); - expect( view.verbose ).toBe(true); - }); + test('Verbose mode CLI status with category list should correctly initialize ViewResourceTableParams [Non-Help]', () => { + const cliParams: CLIParams = { + cliCommand: 'status', + cliSubcommands: undefined, + cliOptions: { + storage: true, + api: true, + verbose: true, + yes: false, + }, + }; + const view = new ViewResourceTableParams(cliParams); + expect(view.command).toBe('status'); + expect(view.categoryList).toStrictEqual(['storage', 'api']); + expect(view.help).toBe(false); + expect(view.verbose).toBe(true); + }); - test('Status Help CLI should correctly return styled help message', () => { - const cliParams : CLIParams = { - cliCommand: 'status', - cliSubcommands: [ 'help' ], - cliOptions: { yes: false } - }; + test('Status Help CLI should correctly return styled help message', () => { + const cliParams: CLIParams = { + cliCommand: 'status', + cliSubcommands: ['help'], + cliOptions: { yes: false }, + }; - const view = new ViewResourceTableParams(cliParams); - expect( view.command ).toBe("status"); - expect( view.categoryList).toStrictEqual([]); - expect( view.help ).toBe(true); - expect( view.verbose ).toBe(false); - const styledHelp = stripAnsi(chalk.reset(view.getStyledHelp())); - expect(styledHelp).toMatchSnapshot(); - }); + const view = new ViewResourceTableParams(cliParams); + expect(view.command).toBe('status'); + expect(view.categoryList).toStrictEqual([]); + expect(view.help).toBe(true); + expect(view.verbose).toBe(false); + const styledHelp = stripAnsi(chalk.reset(view.getStyledHelp())); + expect(styledHelp).toMatchSnapshot(); + }); - test('Status Command should print error message to the screen', () => { - const cliParams : CLIParams = { - cliCommand: 'status', - cliSubcommands: [ 'help' ], - cliOptions: { yes: false } - }; - const view = new ViewResourceTableParams(cliParams); - const errorMockFn = jest.fn(); + test('Status Command should print error message to the screen', () => { + const cliParams: CLIParams = { + cliCommand: 'status', + cliSubcommands: ['help'], + cliOptions: { yes: false }, + }; + const view = new ViewResourceTableParams(cliParams); + const errorMockFn = jest.fn(); - const context: any = { - print : { - error: errorMockFn - } - }; - const errorMessage = "Something bad happened" - try { - throw new Error(errorMessage); - } - catch(e) { - view.logErrorException(e, context); - expect(errorMockFn).toBeCalledTimes(1); - } - - }); - -} ); \ No newline at end of file + const context: any = { + print: { + error: errorMockFn, + }, + }; + const errorMessage = 'Something bad happened'; + try { + throw new Error(errorMessage); + } catch (e) { + view.logErrorException(e, context); + expect(errorMockFn).toBeCalledTimes(1); + } + }); +}); diff --git a/packages/amplify-cli-core/src/category-interfaces/category-base-schema-generator.ts b/packages/amplify-cli-core/src/category-interfaces/category-base-schema-generator.ts index 42b63d363bb..29ee24e6a8b 100644 --- a/packages/amplify-cli-core/src/category-interfaces/category-base-schema-generator.ts +++ b/packages/amplify-cli-core/src/category-interfaces/category-base-schema-generator.ts @@ -4,10 +4,11 @@ * can be used for run-time validation of Walkthrough/Headless structures. */ import { getProgramFromFiles, buildGenerator, PartialArgs } from 'typescript-json-schema'; -import fs from 'fs-extra'; -import path from 'path'; +import * as fs from 'fs-extra'; +import * as path from 'path'; import Ajv from 'ajv'; import { printer } from 'amplify-prompts'; +import { $TSAny, JSONUtilities } from '..'; // Interface types are expected to be exported as "typeName" in the file export type TypeDef = { @@ -17,8 +18,8 @@ export type TypeDef = { export class CLIInputSchemaGenerator { // Paths are relative to the package root - TYPES_SRC_ROOT = path.join('.','src','provider-utils','awscloudformation','service-walkthrough-types'); - SCHEMA_FILES_ROOT = path.join('.','resources','schemas'); + TYPES_SRC_ROOT = path.join('.', 'src', 'provider-utils', 'awscloudformation', 'service-walkthrough-types'); + SCHEMA_FILES_ROOT = path.join('.', 'resources', 'schemas'); OVERWRITE_SCHEMA_FLAG = '--overwrite'; private serviceTypeDefs: TypeDef[]; @@ -32,7 +33,7 @@ export class CLIInputSchemaGenerator { } private getTypesSrcRootForSvc(normalizedSvcName: string): string { - return path.join(this.TYPES_SRC_ROOT,`${normalizedSvcName}-user-input-types.ts`); + return path.join(this.TYPES_SRC_ROOT, `${normalizedSvcName}-user-input-types.ts`); } /** @@ -41,9 +42,11 @@ export class CLIInputSchemaGenerator { * @param svcName * @returns normalizedSvcName */ - private normalizeServiceToFilePrefix( svcName : string ): string { - return `${svcName[0].toLowerCase()}${svcName.slice(1)}` + private normalizeServiceToFilePrefix(serviceName: string): string { + serviceName = serviceName.replace(' ', ''); + return `${serviceName[0].toLowerCase()}${serviceName.slice(1)}`; } + private printWarningSchemaFileExists() { printer.info('The interface version must be bumped after any changes.'); printer.info(`Use the ${this.OVERWRITE_SCHEMA_FLAG} flag to overwrite existing versions`); @@ -55,7 +58,7 @@ export class CLIInputSchemaGenerator { printer.info(`Output Path: ${schemaFilePath}`); } - private printGeneratingSchemaMessage(svcAbsoluteFilePath: string, serviceName : string){ + private printGeneratingSchemaMessage(svcAbsoluteFilePath: string, serviceName: string) { printer.info(`Generating Schema for ${serviceName}`); printer.info(`Input Path: ${svcAbsoluteFilePath}`); } @@ -74,7 +77,7 @@ export class CLIInputSchemaGenerator { }; for (const typeDef of this.serviceTypeDefs) { - const normalizedServiceName = this.normalizeServiceToFilePrefix( typeDef.service ) ; + const normalizedServiceName = this.normalizeServiceToFilePrefix(typeDef.service); //get absolute file path to the user-input types for the given service const svcAbsoluteFilePath = this.getSvcFileAbsolutePath(normalizedServiceName); this.printGeneratingSchemaMessage(svcAbsoluteFilePath, typeDef.service); @@ -91,7 +94,7 @@ export class CLIInputSchemaGenerator { return generatedFilePaths; } fs.ensureFileSync(outputSchemaFilePath); - fs.writeFileSync(outputSchemaFilePath, JSON.stringify(typeSchema, undefined, 4)); + JSONUtilities.writeJson(outputSchemaFilePath, typeSchema); //print success status to the terminal this.printSuccessSchemaFileWritten(outputSchemaFilePath, typeDef.typeName); generatedFilePaths.push(outputSchemaFilePath); @@ -125,12 +128,14 @@ export class CLIInputSchemaValidator { async validateInput(userInput: string): Promise { const userInputSchema = await this.getUserInputSchema(); if (userInputSchema.dependencySchemas) { - userInputSchema.dependencySchemas.reduce((acc: { addSchema: (arg0: any) => any }, it: any) => acc.addSchema(it), this._ajv); + userInputSchema.dependencySchemas.reduce((acc: { addSchema: (arg0: $TSAny) => $TSAny }, it: $TSAny) => acc.addSchema(it), this._ajv); } const validate = this._ajv.compile(userInputSchema); - const input = JSON.parse(userInput); + const input = JSONUtilities.parse(userInput); if (!validate(input) as boolean) { - throw new Error(`Data did not validate against the supplied schema. Underlying errors were ${JSON.stringify(validate.errors)}`); + throw new Error( + `Data did not validate against the supplied schema. Underlying errors were ${JSONUtilities.stringify(validate.errors)}`, + ); } return true; } diff --git a/packages/amplify-cli-core/src/cliConstants.ts b/packages/amplify-cli-core/src/cliConstants.ts index 7dcba841c6e..3205c237b59 100644 --- a/packages/amplify-cli-core/src/cliConstants.ts +++ b/packages/amplify-cli-core/src/cliConstants.ts @@ -17,7 +17,7 @@ export enum CLISubCommandType { CONSOLE = 'console', IMPORT = 'import', OVERRIDE = 'override', - MIGRATE = 'migrate' + MIGRATE = 'migrate', } export const AmplifyCategories = { STORAGE: 'storage', @@ -32,11 +32,13 @@ export const AmplifyCategories = { }; export const AmplifySupportedService = { + APIGW: 'API Gateway', + APPSYNC: 'AppSync', S3: 'S3', DYNAMODB: 'DynamoDB', COGNITO: 'Cognito', COGNITOUSERPOOLGROUPS: 'Cognito-UserPool-Groups', - LAMBDA : 'Lambda' + LAMBDA: 'Lambda', }; export const overriddenCategories = [AmplifyCategories.AUTH, AmplifyCategories.STORAGE]; diff --git a/packages/amplify-cli-core/src/cliViewAPI.ts b/packages/amplify-cli-core/src/cliViewAPI.ts index 365d30b8b74..506e693c192 100644 --- a/packages/amplify-cli-core/src/cliViewAPI.ts +++ b/packages/amplify-cli-core/src/cliViewAPI.ts @@ -3,86 +3,95 @@ import chalk from 'chalk'; import { $TSAny, $TSContext } from '.'; export interface CLIParams { - cliCommand: string; - cliSubcommands: string[] | undefined; - cliOptions: Record; + cliCommand: string; + cliSubcommands: string[] | undefined; + cliOptions: Record; } //Resource Table filter and display params (params used for summary/display view of resource table) export class ViewResourceTableParams { - private _command: string; - private _verbose: boolean; //display table in verbose mode - private _help: boolean; //display help for the command - private _categoryList: string[] | []; //categories to display - private _filteredResourceList: any; //resources to *not* display - TBD define union of valid types - - public get command() { - return this._command; - } - public get verbose() { - return this._verbose; - } - public get help() { - return this._help; - } - public get categoryList() { - return this._categoryList; - } - getCategoryFromCLIOptions(cliOptions: object) { - if (cliOptions) { - return Object.keys(cliOptions) - .filter(key => key != 'verbose' && key !== 'yes') - .map(category => category.toLowerCase()); - } else { - return []; - } - } - styleHeader(str: string) { - return chalk.italic(chalk.bgGray.whiteBright(str)); - } - styleCommand(str: string) { - return chalk.greenBright(str); - } - styleOption(str: string) { - return chalk.yellowBright(str); - } - stylePrompt(str: string) { - return chalk.bold(chalk.yellowBright(str)); + private _command: string; + private _verbose: boolean; //display table in verbose mode + private _help: boolean; //display help for the command + private _categoryList: string[] | []; //categories to display + private _filteredResourceList: $TSAny; //resources to *not* display - TBD define union of valid types + + public get command() { + return this._command; + } + + public get verbose() { + return this._verbose; + } + + public get help() { + return this._help; + } + + public get categoryList() { + return this._categoryList; + } + + getCategoryFromCLIOptions(cliOptions: object) { + if (cliOptions) { + return Object.keys(cliOptions) + .filter(key => key != 'verbose' && key !== 'yes') + .map(category => category.toLowerCase()); + } else { + return []; } - public getStyledHelp() { - return ` + } + + styleHeader(str: string) { + return chalk.italic(chalk.bgGray.whiteBright(str)); + } + + styleCommand(str: string) { + return chalk.greenBright(str); + } + + styleOption(str: string) { + return chalk.yellowBright(str); + } + + stylePrompt(str: string) { + return chalk.bold(chalk.yellowBright(str)); + } + + public getStyledHelp() { + return ` ${this.styleHeader('NAME')} ${this.styleCommand('amplify status')} -- Shows the state of local resources not yet pushed to the cloud (Create/Update/Delete) ${this.styleHeader('SYNOPSIS')} -${this.styleCommand('amplify status')} [${this.styleCommand('-v')}|${this.styleCommand('--verbose')}] [${this.styleOption('category ...')}] +${this.styleCommand('amplify status')} [${this.styleCommand('-v')}|${this.styleCommand('--verbose')}] [${this.styleOption('category ...')}] ${this.styleHeader('DESCRIPTION')} The amplify status command displays the difference between the deployed state and the local state of the application. -The following options are available: +The following options are available: ${this.styleCommand('[category ...]')} : (Summary mode) Displays the summary of local state vs deployed state of the application usage: ${this.stylePrompt('#>')} ${this.styleCommand('amplify status')} ${this.stylePrompt('#>')} ${this.styleCommand('amplify status')} ${this.styleOption('api storage')} -${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. +${this.styleCommand('-v [category ...]')} : (Verbose mode) Displays the cloudformation diff for all resources for the specified category. If no category is provided, it shows the diff for all categories. usage: ${this.stylePrompt('#>')} ${this.styleCommand('amplify status -v')} ${this.stylePrompt('#>')} ${this.styleCommand('amplify status -v ')}${this.styleOption('api storage')} - - `; - } - public logErrorException( e : Error , context : $TSContext ){ - context.print.error(`Name: ${e.name} : Message: ${e.message}`); - } + `; + } - public constructor(cliParams: CLIParams) { - this._command = cliParams.cliCommand; - this._verbose = cliParams.cliOptions?.verbose === true; - this._categoryList = this.getCategoryFromCLIOptions(cliParams.cliOptions); - this._filteredResourceList = []; //TBD - add support to provide resources - this._help = cliParams.cliSubcommands ? cliParams.cliSubcommands.includes('help') : false; - } + public logErrorException(e: Error, context: $TSContext) { + context.print.error(`Name: ${e.name} : Message: ${e.message}`); + } + + public constructor(cliParams: CLIParams) { + this._command = cliParams.cliCommand; + this._verbose = cliParams.cliOptions?.verbose === true; + this._categoryList = this.getCategoryFromCLIOptions(cliParams.cliOptions); + this._filteredResourceList = []; //TBD - add support to provide resources + this._help = cliParams.cliSubcommands ? cliParams.cliSubcommands.includes('help') : false; + } } diff --git a/packages/amplify-cli-core/src/index.ts b/packages/amplify-cli-core/src/index.ts index d964af1cb6d..084fb5b2ec9 100644 --- a/packages/amplify-cli-core/src/index.ts +++ b/packages/amplify-cli-core/src/index.ts @@ -179,10 +179,10 @@ export type GetPackageAssetPaths = () => Promise; export type $IPluginManifest = $TSAny; // Use it for all file content read from amplify-meta.json -export type $TSMeta = any; +export type $TSMeta = $TSAny; // Use it for all file content read from team-provider-info.json -export type $TSTeamProviderInfo = any; +export type $TSTeamProviderInfo = $TSAny; // Use it for all object initializer usages: {} export type $TSObject = Record; @@ -215,7 +215,7 @@ export interface ProviderContext { projectName: string; } -export type $TSCopyJob = any; +export type $TSCopyJob = $TSAny; // Temporary interface until Context refactor interface AmplifyToolkit { @@ -223,9 +223,9 @@ interface AmplifyToolkit { constants: $TSAny; constructExeInfo: (context: $TSContext) => $TSAny; copyBatch: (context: $TSContext, jobs: $TSCopyJob[], props: object, force?: boolean, writeParams?: boolean | object) => $TSAny; - crudFlow: (role: string, permissionMap?: $TSObject, defaults?: $TSAny[]) => $TSAny; + crudFlow: (role: string, permissionMap?: $TSObject, defaults?: string[]) => Promise; deleteProject: () => $TSAny; - executeProviderUtils: (context: $TSContext, providerName: string, utilName: string, options: $TSAny) => $TSAny; + executeProviderUtils: (context: $TSContext, providerName: string, utilName: string, options?: $TSAny) => $TSAny; getAllEnvs: () => string[]; getPlugin: () => $TSAny; getCategoryPluginInfo: (context: $TSContext, category?: string, service?: string) => $TSAny; @@ -237,6 +237,10 @@ interface AmplifyToolkit { getPluginInstance: (context: $TSContext, pluginName: string) => $TSAny; getProjectConfig: () => $TSAny; getProjectDetails: () => $TSAny; + + /** + * @deprecated Use stateManager.getMeta() from amplify-cli-core + */ getProjectMeta: () => $TSMeta; getResourceStatus: (category?: $TSAny, resourceName?: $TSAny, providerName?: $TSAny, filteredResources?: $TSAny) => $TSAny; getResourceOutputs: () => $TSAny; @@ -246,6 +250,10 @@ interface AmplifyToolkit { */ inputValidation: (input: $TSAny) => (value: $TSAny) => boolean | string; listCategories: () => $TSAny; + + /** + * @deprecated use uuid + */ makeId: (n?: number) => string; openEditor: (context: $TSContext, target: string, waitToContinue?: boolean) => Promise; onCategoryOutputsChange: (context: $TSContext, currentAmplifyMeta: $TSMeta | undefined, amplifyMeta?: $TSMeta) => $TSAny; @@ -259,15 +267,23 @@ interface AmplifyToolkit { rebuild?: boolean, ) => $TSAny; storeCurrentCloudBackend: () => $TSAny; + + /** + * @deprecated use stateManager or JSONUtilities from amplify-cli-core + */ readJsonFile: (fileName: string) => $TSAny; removeDeploymentSecrets: (context: $TSContext, category: string, resource: string) => void; removeResource: ( context: $TSContext, category: string, resource: string, - questionOptions?: $TSAny, + questionOptions?: { + headless?: boolean; + serviceSuffix?: { [serviceName: string]: string }; + serviceDeletionInfo?: { [serviceName: string]: string }; + }, resourceNameCallback?: (resourceName: string) => Promise, - ) => $TSAny; + ) => Promise<{ service: string; resourceName: string } | undefined>; sharedQuestions: () => $TSAny; showAllHelp: () => $TSAny; showHelp: (header: string, commands: { name: string; description: string }[]) => $TSAny; @@ -301,17 +317,17 @@ interface AmplifyToolkit { // buildType is from amplify-function-plugin-interface but can't be imported here because it would create a circular dependency updateamplifyMetaAfterBuild: (resource: ResourceTuple, buildType?: string) => void; updateAmplifyMetaAfterPackage: (resource: ResourceTuple, zipFilename: string, hash?: { resourceKey: string; hashValue: string }) => void; - updateBackendConfigAfterResourceAdd: (category: string, resourceName: string, resourceData: $TSAny) => $TSAny; - updateBackendConfigAfterResourceUpdate: () => $TSAny; - updateBackendConfigAfterResourceRemove: () => $TSAny; + updateBackendConfigAfterResourceAdd: (category: string, resourceName: string, resourceData: $TSObject) => void; + updateBackendConfigAfterResourceUpdate: (category: string, resourceName: string, attribute: string, value: $TSAny) => void; + updateBackendConfigAfterResourceRemove: (category: string, resourceName: string) => void; loadEnvResourceParameters: (context: $TSContext, category: string, resourceName: string) => $TSAny; saveEnvResourceParameters: (context: $TSContext, category: string, resourceName: string, envSpecificParams?: $TSObject) => void; removeResourceParameters: (context: $TSContext, category: string, resource: string) => void; triggerFlow: () => $TSAny; addTrigger: () => $TSAny; updateTrigger: () => $TSAny; - deleteTrigger: () => $TSAny; - deleteAllTriggers: () => $TSAny; + deleteTrigger: (context: $TSContext, name: string, dir: string) => Promise; + deleteAllTriggers: (previouslySaved: $TSAny, resourceName: string, targetDir: string, context: $TSContext) => Promise; deleteDeselectedTriggers: () => $TSAny; dependsOnBlock: (context: $TSContext, dependsOnKeys: string[], service: string) => $TSAny; getTriggerMetadata: () => $TSAny; @@ -319,7 +335,7 @@ interface AmplifyToolkit { getTriggerEnvVariables: () => $TSAny; getTriggerEnvInputs: () => $TSAny; getUserPoolGroupList: () => $TSAny[]; - forceRemoveResource: () => $TSAny; + forceRemoveResource: (context: $TSContext, categoryName: string, name: string, dir: string) => $TSAny; writeObjectAsJson: () => $TSAny; hashDir: (dir: string, exclude: string[]) => Promise; leaveBreadcrumbs: (category: string, resourceName: string, breadcrumbs: unknown) => void; @@ -333,5 +349,5 @@ interface AmplifyToolkit { unauthRoleArn?: string; unauthRoleName?: string; }; - invokePluginMethod: (context: $TSContext, category: string, service: string | undefined, method: string, args: any[]) => Promise; + invokePluginMethod: (context: $TSContext, category: string, service: string | undefined, method: string, args: $TSAny[]) => Promise; } diff --git a/packages/amplify-cli-core/src/overrides-manager/override-skeleton-generator.ts b/packages/amplify-cli-core/src/overrides-manager/override-skeleton-generator.ts index 001ff1ca8db..af00f9df708 100644 --- a/packages/amplify-cli-core/src/overrides-manager/override-skeleton-generator.ts +++ b/packages/amplify-cli-core/src/overrides-manager/override-skeleton-generator.ts @@ -1,13 +1,14 @@ -import fs from 'fs-extra'; -import { $TSContext, getPackageManager } from '../index'; +import { printer, prompter } from 'amplify-prompts'; import execa from 'execa'; +import * as fs from 'fs-extra'; +import { EOL } from 'os'; import * as path from 'path'; -import { printer, prompter } from 'amplify-prompts'; +import { $TSAny, $TSContext, getPackageManager, pathManager } from '../index'; import { JSONUtilities } from '../jsonUtilities'; export const generateOverrideSkeleton = async (context: $TSContext, srcResourceDirPath: string, destDirPath: string): Promise => { // 1. Create skeleton package - const backendDir = context.amplify.pathManager.getBackendDirPath(); + const backendDir = pathManager.getBackendDirPath(); const overrideFile = path.join(destDirPath, 'override.ts'); if (fs.existsSync(overrideFile)) { await context.amplify.openEditor(context, overrideFile); @@ -64,11 +65,11 @@ export async function buildOverrideDir(cwd: string, destDirPath: string): Promis encoding: 'utf-8', }); return true; - } catch (error) { - if ((error as any).code === 'ENOENT') { + } catch (error: $TSAny) { + if (error.code === 'ENOENT') { throw new Error(`Packaging overrides failed. Could not find ${packageManager} executable in the PATH.`); } else { - throw new Error(`Packaging overrides failed with the error \n${error.message}`); + throw new Error(`Packaging overrides failed with the error:${EOL}${error.message}`); } } } diff --git a/packages/amplify-cli-core/src/state-manager/stateManager.ts b/packages/amplify-cli-core/src/state-manager/stateManager.ts index cafa3f838ce..a06437a7904 100644 --- a/packages/amplify-cli-core/src/state-manager/stateManager.ts +++ b/packages/amplify-cli-core/src/state-manager/stateManager.ts @@ -290,6 +290,14 @@ export class StateManager { JSONUtilities.writeJson(filePath, inputs); }; + resourceInputsJsonExists = (projectPath: string | undefined, category: string, resourceName: string): boolean => { + try { + return fs.existsSync(pathManager.getResourceInputsJsonFilePath(projectPath, category, resourceName)); + } catch (e) { + return false; + } + }; + cliJSONFileExists = (projectPath: string, env?: string): boolean => { try { return fs.existsSync(pathManager.getCLIJSONFilePath(projectPath, env)); diff --git a/packages/amplify-cli/package.json b/packages/amplify-cli/package.json index 1300d698e56..62a69e133e8 100644 --- a/packages/amplify-cli/package.json +++ b/packages/amplify-cli/package.json @@ -36,7 +36,7 @@ "@aws-cdk/cloudformation-diff": "~1.124.0", "amplify-app": "3.0.16", "amplify-category-analytics": "2.21.24", - "amplify-category-api": "2.33.2", + "@aws-amplify/amplify-category-api": "1.0.0", "@aws-amplify/amplify-category-auth": "1.0.0", "@aws-amplify/amplify-category-custom": "1.0.0", "amplify-category-function": "2.36.1", diff --git a/packages/amplify-cli/src/commands/build-override.ts b/packages/amplify-cli/src/commands/build-override.ts index 96975b9a8b6..3e09d08b0f7 100644 --- a/packages/amplify-cli/src/commands/build-override.ts +++ b/packages/amplify-cli/src/commands/build-override.ts @@ -44,19 +44,19 @@ export const run = async (context: $TSContext) => { export const getResources = async (context: $TSContext): Promise => { const resources: IAmplifyResource[] = []; const { resourcesToBeCreated, resourcesToBeUpdated } = await context.amplify.getResourceStatus(); - resourcesToBeCreated.forEach(resourceCreated => { + resourcesToBeCreated.forEach((resourceCreated: IAmplifyResource) => { resources.push({ - service: resourceCreated.service as string, - category: resourceCreated.category as string, - resourceName: resourceCreated.resourceName as string, + service: resourceCreated.service, + category: resourceCreated.category, + resourceName: resourceCreated.resourceName, }); }); - resourcesToBeUpdated.forEach(resourceUpdated => { + resourcesToBeUpdated.forEach((resourceUpdated: IAmplifyResource) => { resources.push({ - service: resourceUpdated.service as string, - category: resourceUpdated.category as string, - resourceName: resourceUpdated.resourceName as string, + service: resourceUpdated.service, + category: resourceUpdated.category, + resourceName: resourceUpdated.resourceName, }); }); return resources; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/execute-provider-utils.ts b/packages/amplify-cli/src/extensions/amplify-helpers/execute-provider-utils.ts index 96fc64cfd40..934f259e946 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/execute-provider-utils.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/execute-provider-utils.ts @@ -1,8 +1,8 @@ import { $TSAny, $TSContext } from 'amplify-cli-core'; import { getProviderPlugins } from './get-provider-plugins'; -export async function executeProviderUtils(context: $TSContext, providerName: string, utilName: string, options: $TSAny) { +export async function executeProviderUtils(context: $TSContext, providerName: string, utilName: string, options?: $TSAny) { const providerPlugins = getProviderPlugins(context); - const pluginModule = require(providerPlugins[providerName]); + const pluginModule = await import(providerPlugins[providerName]); return pluginModule.providerUtils[utilName](context, options); } diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/get-category-pluginInfo.ts b/packages/amplify-cli/src/extensions/amplify-helpers/get-category-pluginInfo.ts index 8906d3c4913..cc4aa013f9c 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/get-category-pluginInfo.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/get-category-pluginInfo.ts @@ -1,6 +1,6 @@ -import { FeatureFlags } from 'amplify-cli-core'; +import { $TSContext } from 'amplify-cli-core'; -export function getCategoryPluginInfo(context, category, service?) { +export function getCategoryPluginInfo(context: $TSContext, category: string, service?: string) { let categoryPluginInfo; const pluginInfosForCategory = context.pluginPlatform.plugins[category]; @@ -17,9 +17,7 @@ export function getCategoryPluginInfo(context, category, service?) { categoryPluginInfo = pluginInfosForCategory[0]; } } else { - const overidedPlugin = pluginInfosForCategory.find(plugin => { - return plugin.packageName === `@aws-amplify/amplify-category-${category}` && FeatureFlags.getBoolean(`overrides.${category}`); - }); + const overidedPlugin = pluginInfosForCategory.find(plugin => plugin.packageName === `@aws-amplify/amplify-category-${category}`); if (overidedPlugin !== undefined) { return overidedPlugin; } diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/permission-flow.ts b/packages/amplify-cli/src/extensions/amplify-helpers/permission-flow.ts index 78e99d8072c..a4c7fed7fb6 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/permission-flow.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/permission-flow.ts @@ -1,7 +1,7 @@ import * as inquirer from 'inquirer'; import _ from 'lodash'; -export const crudFlow = async (role, permissionMap = {}, defaults = []) => { +export const crudFlow = async (role: string, permissionMap = {}, defaults: string[] = []) => { if (!role) throw new Error('No role provided to permission question flow'); const possibleOperations = Object.keys(permissionMap).map(el => ({ name: el, value: el })); diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/remove-resource.ts b/packages/amplify-cli/src/extensions/amplify-helpers/remove-resource.ts index 4ee3d36c9e0..3a28ecd021c 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/remove-resource.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/remove-resource.ts @@ -37,7 +37,11 @@ export async function removeResource( context: $TSContext, category: string, resourceName?: string, - options: { headless?: boolean; serviceSuffix?: { [serviceName: string]: string }; serviceDeletionInfo?: {} } = { headless: false }, + options: { + headless?: boolean; + serviceSuffix?: { [serviceName: string]: string }; + serviceDeletionInfo?: { [serviceName: string]: string }; + } = { headless: false }, resourceNameCallback?: (resourceName: string) => Promise, ) { const amplifyMeta = stateManager.getMeta(); @@ -135,8 +139,9 @@ const deleteResourceFiles = async (context: $TSContext, category: string, resour } }); } + const serviceName: string = amplifyMeta[category][resourceName].service; const resourceValues = { - service: amplifyMeta[category][resourceName].service, + service: serviceName, resourceName, }; if (amplifyMeta[category][resourceName] !== undefined) { diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/trigger-flow.ts b/packages/amplify-cli/src/extensions/amplify-helpers/trigger-flow.ts index 787d81ccf1b..aeb9f850bc5 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/trigger-flow.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/trigger-flow.ts @@ -1,10 +1,10 @@ -import * as inquirer from 'inquirer'; +import { $TSAny, $TSContext, $TSObject, exitOnNextTick, JSONUtilities } from 'amplify-cli-core'; import chalk from 'chalk'; import * as fs from 'fs-extra'; -import * as path from 'path'; -import _ from 'lodash'; -import { exitOnNextTick, JSONUtilities, $TSAny } from 'amplify-cli-core'; +import * as inquirer from 'inquirer'; import Separator from 'inquirer/lib/objects/separator'; +import _ from 'lodash'; +import * as path from 'path'; // keep in sync with ServiceName in amplify-category-function, but probably it will not change const FunctionServiceNameLambdaFunction = 'Lambda'; @@ -211,7 +211,7 @@ export const deleteDeselectedTriggers = async (currentTriggers, previousTriggers } }; -export const deleteTrigger = async (context, name, dir) => { +export const deleteTrigger = async (context: $TSContext, name: string, dir: string) => { try { await context.amplify.forceRemoveResource(context, 'function', name, dir); } catch (e) { @@ -219,7 +219,7 @@ export const deleteTrigger = async (context, name, dir) => { } }; -export const deleteAllTriggers = async (triggers, functionName, dir, context) => { +export const deleteAllTriggers = async (triggers: $TSObject, functionName: string, dir: string, context: $TSContext) => { const previousKeys = Object.keys(triggers); for (let y = 0; y < previousKeys.length; y += 1) { const targetPath = `${dir}/function/${functionName}`; diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/update-backend-config.ts b/packages/amplify-cli/src/extensions/amplify-helpers/update-backend-config.ts index 1ae624d5487..9f2ac9f66e2 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/update-backend-config.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/update-backend-config.ts @@ -1,7 +1,7 @@ +import { $TSAny, $TSObject, stateManager } from 'amplify-cli-core'; import _ from 'lodash'; -import { stateManager } from 'amplify-cli-core'; -export function updateBackendConfigAfterResourceAdd(category, resourceName, options) { +export function updateBackendConfigAfterResourceAdd(category: string, resourceName: string, options: $TSObject) { const backendConfig = stateManager.getBackendConfig(undefined, { throwIfNotExist: false, default: {}, @@ -20,7 +20,7 @@ export function updateBackendConfigAfterResourceAdd(category, resourceName, opti stateManager.setBackendConfig(undefined, backendConfig); } -export function updateBackendConfigAfterResourceUpdate(category, resourceName, attribute, value) { +export function updateBackendConfigAfterResourceUpdate(category: string, resourceName: string, attribute: string, value: $TSAny) { const backendConfig = stateManager.getBackendConfig(undefined, { throwIfNotExist: false, default: {}, @@ -31,7 +31,7 @@ export function updateBackendConfigAfterResourceUpdate(category, resourceName, a stateManager.setBackendConfig(undefined, backendConfig); } -export function updateBackendConfigAfterResourceRemove(category, resourceName) { +export function updateBackendConfigAfterResourceRemove(category: string, resourceName: string) { const backendConfig = stateManager.getBackendConfig(undefined, { throwIfNotExist: false, default: {}, diff --git a/packages/amplify-container-hosting/lib/ElasticContainer/index.js b/packages/amplify-container-hosting/lib/ElasticContainer/index.js index a19956e57d8..f37512d1985 100644 --- a/packages/amplify-container-hosting/lib/ElasticContainer/index.js +++ b/packages/amplify-container-hosting/lib/ElasticContainer/index.js @@ -6,7 +6,7 @@ const path = require('path'); const constants = require('../constants'); -const { EcsAlbStack, NETWORK_STACK_LOGICAL_ID, DEPLOYMENT_MECHANISM, processDockerConfig } = require('amplify-category-api'); +const { EcsAlbStack, NETWORK_STACK_LOGICAL_ID, DEPLOYMENT_MECHANISM, processDockerConfig } = require('@aws-amplify/amplify-category-api'); const { open } = require('amplify-cli-core'); const serviceName = 'ElasticContainer'; @@ -228,7 +228,7 @@ export async function generateHostingResources( const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - /** @type {import('amplify-category-api').ApiResource & {service: string, domain: string, providerPlugin:string, hostedZoneId: string, iamAccessUnavailable: boolean}} */ + /** @type {import('@aws-amplify/amplify-category-api').ApiResource & {service: string, domain: string, providerPlugin:string, hostedZoneId: string, iamAccessUnavailable: boolean}} */ const resource = { resourceName, service: serviceName, diff --git a/packages/amplify-container-hosting/package.json b/packages/amplify-container-hosting/package.json index c8ad678787b..679de427cea 100644 --- a/packages/amplify-container-hosting/package.json +++ b/packages/amplify-container-hosting/package.json @@ -18,7 +18,7 @@ "test": "jest --coverage --passWithNoTests" }, "dependencies": { - "amplify-category-api": "2.33.2", + "@aws-amplify/amplify-category-api": "1.0.0", "chalk": "^4.1.1", "fs-extra": "^8.1.0", "inquirer": "^7.3.3", diff --git a/packages/amplify-e2e-core/src/categories/api.ts b/packages/amplify-e2e-core/src/categories/api.ts index e7bbb8fc8d7..479409002e5 100644 --- a/packages/amplify-e2e-core/src/categories/api.ts +++ b/packages/amplify-e2e-core/src/categories/api.ts @@ -1,10 +1,9 @@ -import { getCLIPath, updateSchema, nspawn as spawn, KEY_DOWN_ARROW } from '..'; import * as fs from 'fs-extra'; +import _ from 'lodash'; import * as path from 'path'; +import { ExecutionContext, getCLIPath, nspawn as spawn, RETURN, updateSchema } from '..'; +import { multiSelect, singleSelect } from '../utils/selectors'; import { selectRuntime, selectTemplate } from './lambda-function'; -import { singleSelect, multiSelect } from '../utils/selectors'; -import _ from 'lodash'; -import { EOL } from 'os'; import { modifiedApi } from './resources/modified-api-index'; export function getSchemaPath(schemaName: string): string { @@ -401,126 +400,134 @@ export function updateAPIWithResolutionStrategyWithModels(cwd: string, settings: } // Either settings.existingLambda or settings.isCrud is required -export function addRestApi(cwd: string, settings: any) { + +type RestApiSettings = { + allowGuestUsers?: boolean; + existingLambda?: boolean; + isFirstRestApi?: boolean; + isCrud?: boolean; + path?: string; + resourceName?: string; + restrictAccess?: boolean; +}; + +export function addRestApi(cwd: string, settings: RestApiSettings) { return new Promise((resolve, reject) => { - if (!('existingLambda' in settings) && !('isCrud' in settings)) { - reject(new Error('Missing property in settings object in addRestApi()')); - } else { - const isFirstRestApi = settings.isFirstRestApi ?? true; - let chain = spawn(getCLIPath(), ['add', 'api'], { cwd, stripColors: true }) - .wait('Select from one of the below mentioned services') - .send(KEY_DOWN_ARROW) - .sendCarriageReturn(); // REST - - if (!isFirstRestApi) { - chain.wait('Would you like to add a new path to an existing REST API'); - - if (settings.path) { - chain - .sendConfirmYes() - .wait('Please select the REST API you would want to update') - .sendCarriageReturn() // Select the first REST API - .wait('Provide a path') - .sendLine(settings.path) - .wait('Choose a lambda source') - .send(KEY_DOWN_ARROW) - .sendCarriageReturn() // Existing lambda - .wait('Choose the Lambda function to invoke by this path') - .sendCarriageReturn() // Pick first one - .wait('Restrict API access') - .sendConfirmNo() // Do not restrict access - .wait('Do you want to add another path') - .sendConfirmNo() // Do not add another path - .sendEof() - .run((err: Error) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - return; - } else { - chain.sendConfirmNo(); - } - } + const isFirstRestApi = settings.isFirstRestApi ?? true; + const chain = spawn(getCLIPath(), ['add', 'api'], { cwd, stripColors: true }) + .wait('Select from one of the below mentioned services') + .sendKeyDown() + .sendCarriageReturn(); // REST - chain - .wait('Provide a friendly name for your resource to be used as a label for this category in the project') - .sendCarriageReturn() - .wait('Provide a path') - .sendCarriageReturn() - .wait('Choose a lambda source'); + if (!isFirstRestApi) { + chain.wait('Would you like to add a new path to an existing REST API'); - if (settings.existingLambda) { + if (settings.path) { chain - .send(KEY_DOWN_ARROW) + .sendConfirmYes() + .wait('Select the REST API you would want to update') + .sendCarriageReturn() // Select the first REST API + .wait('Provide a path') + .sendLine(settings.path) + .wait('Choose a lambda source') + .sendKeyDown() .sendCarriageReturn() // Existing lambda .wait('Choose the Lambda function to invoke by this path') - .sendCarriageReturn(); // Pick first one + .sendCarriageReturn() // Pick first one + .wait('Restrict API access') + .sendConfirmNo() // Do not restrict access + .wait('Do you want to add another path') + .sendConfirmNo() // Do not add another path + .sendEof() + .run((err: Error) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + return; } else { - chain - .sendCarriageReturn() // Create new Lambda function - .wait('Provide an AWS Lambda function name') - .sendCarriageReturn(); - - selectRuntime(chain, 'nodejs'); - - const templateName = settings.isCrud - ? 'CRUD function for DynamoDB (Integration with API Gateway)' - : 'Serverless ExpressJS function (Integration with API Gateway)'; - selectTemplate(chain, templateName, 'nodejs'); - - if (settings.isCrud) { - chain - .wait('Choose a DynamoDB data source option') - .sendCarriageReturn() // Use DDB table configured in current project - .wait('Choose from one of the already configured DynamoDB tables') - .sendCarriageReturn(); // Use first one in the list - } - - chain - .wait('Do you want to configure advanced settings?') - .sendConfirmNo() - .wait('Do you want to edit the local lambda function now') - .sendConfirmNo(); + chain.sendConfirmNo(); } + } - chain.wait('Restrict API access'); + chain + .wait('Provide a friendly name for your resource to be used as a label for this category in the project') + .sendLine(settings.resourceName ?? RETURN) + .wait('Provide a path') + .sendCarriageReturn() + .wait('Choose a lambda source'); - if (settings.restrictAccess) { - chain.sendConfirmYes().wait('Who should have access'); + if (settings.existingLambda) { + chain + .sendKeyDown() + .sendCarriageReturn() // Existing lambda + .wait('Choose the Lambda function to invoke by this path') + .sendCarriageReturn(); // Pick first one + } else { + chain + .sendCarriageReturn() // Create new Lambda function + .wait('Provide an AWS Lambda function name') + .sendCarriageReturn(); - if (!settings.allowGuestUsers) { - chain - .sendCarriageReturn() // Authenticated users only - .wait('What kind of access do you want for Authenticated users') - .sendLine('a'); // CRUD permissions - } else { - chain - .sendLine(KEY_DOWN_ARROW) - .sendCarriageReturn() // Authenticated and Guest users - .wait('What kind of access do you want for Authenticated users') - .sendLine('a') // CRUD permissions for authenticated users - .wait('What kind of access do you want for Guest users') - .sendLine('a'); // CRUD permissions for guest users - } - } else { - chain.sendConfirmNo(); // Do not restrict access + selectRuntime(chain, 'nodejs'); + + const templateName = settings.isCrud + ? 'CRUD function for DynamoDB (Integration with API Gateway)' + : 'Serverless ExpressJS function (Integration with API Gateway)'; + selectTemplate(chain, templateName, 'nodejs'); + + if (settings.isCrud) { + chain + .wait('Choose a DynamoDB data source option') + .sendCarriageReturn() // Use DDB table configured in current project + .wait('Choose from one of the already configured DynamoDB tables') + .sendCarriageReturn(); // Use first one in the list } chain - .wait('Do you want to add another path') + .wait('Do you want to configure advanced settings?') .sendConfirmNo() - .sendEof() - .run((err: Error) => { - if (!err) { - resolve(); - } else { - reject(err); - } - }); + .wait('Do you want to edit the local lambda function now') + .sendConfirmNo(); + } + + chain.wait('Restrict API access'); + + if (settings.restrictAccess) { + chain.sendConfirmYes().wait('Who should have access'); + + if (!settings.allowGuestUsers) { + chain + .sendCarriageReturn() // Authenticated users only + .wait('What kind of access do you want for Authenticated users') + .sendLine('a'); // CRUD permissions + } else { + chain + .sendKeyDown() + .sendCarriageReturn() // Authenticated and Guest users + .wait('What kind of access do you want for Authenticated users') + .sendLine('a') // CRUD permissions for authenticated users + .wait('What kind of access do you want for Guest users') + .sendKeyDown() + .send(' '); // R permissions for guest users + } + } else { + chain.sendConfirmNo(); // Do not restrict access } + + chain + .wait('Do you want to add another path') + .sendConfirmNo() + .sendEof() + .run((err: Error) => { + if (!err) { + resolve(); + } else { + reject(err); + } + }); }); } @@ -584,7 +591,7 @@ export function addApi(projectDir: string, settings?: any) { }); } -function setupAuthType(authType: string, chain: any, settings?: any) { +function setupAuthType(authType: string, chain: ExecutionContext, settings?: any) { switch (authType) { case 'API key': setupAPIKey(chain); @@ -601,7 +608,7 @@ function setupAuthType(authType: string, chain: any, settings?: any) { } } -function setupAPIKey(chain: any) { +function setupAPIKey(chain: ExecutionContext) { chain .wait('Enter a description for the API key') .sendCarriageReturn() @@ -609,7 +616,7 @@ function setupAPIKey(chain: any) { .sendCarriageReturn(); } -function setupCognitoUserPool(chain: any) { +function setupCognitoUserPool(chain: ExecutionContext) { chain .wait('Do you want to use the default authentication and security configuration') .sendCarriageReturn() @@ -623,7 +630,7 @@ function setupIAM(chain: any) { //no need to do anything } -function setupOIDC(chain: any, settings?: any) { +function setupOIDC(chain: ExecutionContext, settings?: any) { if (!settings || !settings['OpenID Connect']) { throw new Error('Must provide OIDC auth settings.'); } diff --git a/packages/amplify-e2e-core/src/categories/auth.ts b/packages/amplify-e2e-core/src/categories/auth.ts index 2c35e55002c..031294747b1 100644 --- a/packages/amplify-e2e-core/src/categories/auth.ts +++ b/packages/amplify-e2e-core/src/categories/auth.ts @@ -949,7 +949,7 @@ export function addAuthWithGroups(cwd: string): Promise { } // creates 2 groups: Admins, Users -export function addAuthWithGroupsAndAdminAPI(cwd: string, settings: any): Promise { +export function addAuthWithGroupsAndAdminAPI(cwd: string, settings?: any): Promise { return new Promise((resolve, reject) => { spawn(getCLIPath(), ['add', 'auth'], { cwd, stripColors: true }) .wait('Do you want to use the default authentication and security configuration') diff --git a/packages/amplify-e2e-core/src/utils/nexpect.ts b/packages/amplify-e2e-core/src/utils/nexpect.ts index 264f0094711..049a1724137 100644 --- a/packages/amplify-e2e-core/src/utils/nexpect.ts +++ b/packages/amplify-e2e-core/src/utils/nexpect.ts @@ -23,7 +23,7 @@ import { join, parse } from 'path'; import * as fs from 'fs-extra'; import * as os from 'os'; import { getScriptRunnerPath, isTestingWithLatestCodebase } from '..'; -const RETURN = process.platform === 'win32' ? '\r' : EOL; +export const RETURN = process.platform === 'win32' ? '\r' : EOL; const DEFAULT_NO_OUTPUT_TIMEOUT = process.env.AMPLIFY_TEST_TIMEOUT_SEC ? Number.parseInt(process.env.AMPLIFY_TEST_TIMEOUT_SEC, 10) * 1000 : 5 * 60 * 1000; // 5 Minutes diff --git a/packages/amplify-e2e-tests/src/__tests__/apigw.test.ts b/packages/amplify-e2e-tests/src/__tests__/apigw.test.ts new file mode 100644 index 00000000000..1ec80659e8d --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/apigw.test.ts @@ -0,0 +1,40 @@ +import { + addRestApi, + createNewProjectDir, + initJSProjectWithProfile, + deleteProject, + deleteProjectDir, + getProjectMeta, + amplifyPushAuth, + addAuthWithGroupsAndAdminAPI, +} from 'amplify-e2e-core'; +import { v4 as uuid } from 'uuid'; + +const [shortId] = uuid().split('-'); +const projName = `apigwtest${shortId}`; + +let projRoot: string; +beforeAll(async () => { + projRoot = await createNewProjectDir(projName); + await initJSProjectWithProfile(projRoot, { name: projName }); +}); + +afterAll(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); +}); + +describe('API Gateway e2e tests', () => { + it('adds multiple rest apis and pushes', async () => { + await addRestApi(projRoot, {}); + await amplifyPushAuth(projRoot); + await addAuthWithGroupsAndAdminAPI(projRoot); // Groups: Admins, Users + await amplifyPushAuth(projRoot); + await addRestApi(projRoot, { isFirstRestApi: false, path: '/foo' }); + await addRestApi(projRoot, { restrictAccess: true, allowGuestUsers: true }); + await amplifyPushAuth(projRoot); // Pushes multiple rest api updates + const projMeta = getProjectMeta(projRoot); + expect(projMeta).toBeDefined(); + expect(projMeta.api).toBeDefined(); + }); +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/auth_5.test.ts b/packages/amplify-e2e-tests/src/__tests__/auth_5.test.ts index 26a208c642f..96a3d91a4d6 100644 --- a/packages/amplify-e2e-tests/src/__tests__/auth_5.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/auth_5.test.ts @@ -190,7 +190,7 @@ describe('headless auth', () => { await initJSProjectWithProfile(projRoot, defaultsSettings); await addAuthWithDefault(projRoot, {}); - await updateHeadlessAuth(projRoot, updateAuthRequest); + await updateHeadlessAuth(projRoot, updateAuthRequest, {}); await amplifyPushAuth(projRoot); const meta = getProjectMeta(projRoot); const id = Object.keys(meta.auth).map(key => meta.auth[key])[0].output.UserPoolId; diff --git a/packages/amplify-e2e-tests/tsconfig.json b/packages/amplify-e2e-tests/tsconfig.json index 0d0ec689241..7a63b4cb0c6 100644 --- a/packages/amplify-e2e-tests/tsconfig.json +++ b/packages/amplify-e2e-tests/tsconfig.json @@ -13,7 +13,7 @@ "typeRoots": ["../../node_modules/@types", "node_modules/@types", "./typings"] }, "references": [ - {"path": "../amplify-e2e-core"}, + { "path": "../amplify-e2e-core" } ], "exclude": ["node_modules", "lib", "__tests__", "custom-resources", "overrides"] -} \ No newline at end of file +} diff --git a/packages/amplify-e2e-tests/tsconfig.tests.json b/packages/amplify-e2e-tests/tsconfig.tests.json index 1cb95fd0b68..6807354862a 100644 --- a/packages/amplify-e2e-tests/tsconfig.tests.json +++ b/packages/amplify-e2e-tests/tsconfig.tests.json @@ -13,5 +13,5 @@ "typeRoots": ["../../node_modules/@types", "node_modules/@types", "./typings"] }, "references": [{ "path": "../amplify-e2e-core" }], - "exclude": ["node_modules", "lib", "__tests__"] + "exclude": ["node_modules", "lib", "__tests__", "custom-resources", "overrides"] }