UNPKG

@aws-amplify/amplify-category-api

Version:
740 lines (620 loc) 25.1 kB
import os from 'os'; import { $TSContext, AmplifyCategories, AmplifySupportedService, exitOnNextTick, isResourceNameUnique, open, pathManager, ResourceDoesNotExistError, stateManager, } from '@aws-amplify/amplify-cli-core'; import { byValues, printer, prompter } from '@aws-amplify/amplify-prompts'; import inquirer from 'inquirer'; import _ from 'lodash'; import { v4 as uuid } from 'uuid'; import { ADMIN_QUERIES_NAME } from '../../../category-constants'; 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'; const category = AmplifyCategories.API; const serviceName = AmplifySupportedService.APIGW; const elasticContainerServiceName = 'ElasticContainer'; export async function serviceWalkthrough(context: $TSContext): ApigwWalkthroughReturnPromise { const allDefaultValues = getAllDefaults(context.amplify.getProjectDetails()); const resourceName = await askApiName(context, allDefaultValues.resourceName); const answers = { paths: {}, resourceName, dependsOn: undefined }; return pathFlow(context, answers); } export async function updateWalkthrough(context: $TSContext) { const { allResources } = await context.amplify.getResourceStatus(); const allDefaultValues = getAllDefaults(context.amplify.getProjectDetails()); const resources = allResources .filter((resource) => resource.service === serviceName && resource.mobileHubMigrated !== true) .map((resource) => resource.resourceName); if (resources.length === 0) { 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 = { paths: [], }; 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') { updateApiOperation = 'add'; } if (selectedApiName === ADMIN_QUERIES_NAME) { const errMessage = `The Admin Queries API is maintained through the Auth category and should be updated using 'amplify update auth' command`; printer.warn(errMessage); await context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); exitOnNextTick(0); } const projRoot = pathManager.findProjectRoot(); if (!stateManager.resourceInputsJsonExists(projRoot, category, selectedApiName)) { // Not yet migrated await migrate(context, projRoot, selectedApiName); // chose not to migrate if (!stateManager.resourceInputsJsonExists(projRoot, category, selectedApiName)) { exitOnNextTick(0); } } const parameters = stateManager.getResourceInputsJson(projRoot, category, selectedApiName); parameters.resourceName = selectedApiName; Object.assign(allDefaultValues, parameters); answers = { ...answers, ...parameters }; [answers.uuid] = uuid().split('-'); const pathNames = Object.keys(answers.paths); let updatedResult = {}; switch (updateApiOperation) { case 'add': { updatedResult = pathFlow(context, answers); break; } case 'remove': { const pathToRemove = await inquirer.prompt({ name: 'path', message: 'Select the path to remove', type: 'list', choices: pathNames, }); delete answers.paths[pathToRemove.path]; const { dependsOn, functionArns } = await findDependsOn(answers.paths); answers.dependsOn = dependsOn; answers.functionArns = functionArns; updatedResult = { answers }; break; } case 'update': { const pathToEdit = await inquirer.prompt({ name: 'pathName', message: 'Select the path to edit', type: 'list', choices: pathNames, }); // removing path from paths list const currentPath: ApigwPath = answers.paths[pathToEdit.pathName]; delete answers.paths[pathToEdit.pathName]; updatedResult = pathFlow(context, answers, currentPath); break; } default: { throw new Error(`Unrecognized API update operation "${updateApiOperation}"`); } } return updatedResult; } async function pathFlow(context: $TSContext, answers: ApigwAnswers, currentPath?: ApigwPath): ApigwWalkthroughReturnPromise { const pathsAnswer = await askPaths(context, answers, currentPath); return { answers: pathsAnswer }; } async function askApiName(context: $TSContext, defaultResourceName: string) { const apiNameValidator = (input: string) => { const amplifyValidatorOutput = context.amplify.inputValidation({ validation: { operator: 'regex', value: '^[a-zA-Z0-9]+$', onErrorMsg: 'Resource name should be alphanumeric', }, required: true, })(input); const adminQueriesName = 'AdminQueries'; if (input === adminQueriesName) { return `${adminQueriesName} is a reserved name for REST API resources for use by the auth category. Run "amplify update auth" to create an Admin Queries API.`; } let uniqueCheck = false; try { uniqueCheck = isResourceNameUnique(category, input); } catch (e) { return e.message || e; } return typeof amplifyValidatorOutput === 'string' ? amplifyValidatorOutput : uniqueCheck; }; 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 resourceName; } async function askPermissions( context: $TSContext, answers: Record<string, any>, currentPath?: ApigwPath, ): Promise<{ setting?: PermissionSetting; auth?: CrudOperation[]; open?: boolean; userPoolGroups?: Record<string, any>; guest?: CrudOperation[]; }> { while (true) { const apiAccess = await prompter.yesOrNo('Restrict API access?', currentPath?.permissions?.setting !== PermissionSetting.OPEN); if (!apiAccess) { return { setting: PermissionSetting.OPEN }; } const userPoolGroupList = context.amplify.getUserPoolGroupList(); let permissionSelected = 'Auth/Guest Users'; const permissions: Record<string, any> = {}; if (userPoolGroupList.length > 0) { do { if (permissionSelected === 'Learn more') { 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.', ); printer.blankLine(); } 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 permissionSetting = await prompter.pick<'one', string>( 'Who should have access?', [ { name: 'Authenticated users only', value: PermissionSetting.PRIVATE, }, { name: 'Authenticated and Guest users', value: PermissionSetting.PROTECTED, }, ], { initial: currentPath?.permissions?.setting === PermissionSetting.PROTECTED ? 1 : 0 }, ); permissions.setting = permissionSetting; let { permissions: { auth: authPermissions }, } = currentPath || { permissions: { auth: [] } }; let { permissions: { guest: unauthPermissions }, } = currentPath || { permissions: { guest: [] } }; if (permissionSetting === PermissionSetting.PRIVATE) { permissions.auth = await askCRUD('Authenticated', authPermissions); const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool' }; await ensureAuth(context, apiRequirements, answers.resourceName); } if (permissionSetting === PermissionSetting.PROTECTED) { permissions.auth = await askCRUD('Authenticated', authPermissions); permissions.guest = await askCRUD('Guest', unauthPermissions); const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool', allowUnauthenticatedIdentities: true }; await ensureAuth(context, apiRequirements, answers.resourceName); } } if (permissionSelected === 'Both' || permissionSelected === 'Individual Groups') { // Enable Auth if not enabled const apiRequirements: ApiRequirements = { authSelections: 'identityPoolAndUserPool' }; await ensureAuth(context, apiRequirements, answers.resourceName); // Get Auth resource name const authResourceName = getAuthResourceName(); answers.authResourceName = authResourceName; let defaultSelectedGroups: string[] = []; if (currentPath?.permissions?.groups) { defaultSelectedGroups = Object.keys(currentPath.permissions.groups); } let selectedUserPoolGroupList = await prompter.pick<'many', string>('Select groups:', userPoolGroupList, { initial: byValues(defaultSelectedGroups)(userPoolGroupList), returnSize: 'many', pickAtLeast: 1, }); // if single user pool group is selected, convert to array if (selectedUserPoolGroupList && !Array.isArray(selectedUserPoolGroupList)) { selectedUserPoolGroupList = [selectedUserPoolGroupList]; } for (const selectedUserPoolGroup of selectedUserPoolGroupList) { let defaults = []; if (currentPath?.permissions?.groups?.[selectedUserPoolGroup]) { defaults = currentPath.permissions.groups[selectedUserPoolGroup]; } if (!permissions.groups) { permissions.groups = {}; } permissions.groups[selectedUserPoolGroup] = await askCRUD(selectedUserPoolGroup, defaults); } if (!permissions.setting) { permissions.setting = PermissionSetting.PRIVATE; } } return permissions; } } async function ensureAuth(context: $TSContext, apiRequirements: ApiRequirements, resourceName: string) { const checkResult: any = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'checkRequirements', [ apiRequirements, context, 'api', resourceName, ]); // If auth is imported and configured, we have to throw the error instead of printing since there is no way to adjust the auth // configuration. if (checkResult.authImported === true && checkResult.errors && checkResult.errors.length > 0) { throw new Error(checkResult.errors.join(os.EOL)); } if (checkResult.errors && checkResult.errors.length > 0) { printer.warn(checkResult.errors.join(os.EOL)); } // If auth is not imported and there were errors, adjust or enable auth configuration if (!checkResult.authEnabled || !checkResult.requirementsMet) { try { await context.amplify.invokePluginMethod(context, 'auth', undefined, 'externalAuthEnable', [ context, AmplifyCategories.API, resourceName, apiRequirements, ]); } catch (error) { printer.error(error); throw error; } } } async function askCRUD(userType: string, permissions: CrudOperation[] = []) { const crudOptions = [CrudOperation.CREATE, CrudOperation.READ, CrudOperation.UPDATE, CrudOperation.DELETE]; const crudAnswers = await prompter.pick<'many', string>(`What permissions do you want to grant to ${userType} users?`, crudOptions, { returnSize: 'many', initial: byValues(permissions), pickAtLeast: 1, }); return crudAnswers; } async function askPaths(context: $TSContext, answers: Record<string, any>, currentPath?: ApigwPath): Promise<ApigwAnswers> { const existingFunctions = functionsExist(); let defaultFunctionType = 'newFunction'; const defaultChoice = { name: 'Create a new Lambda function', value: defaultFunctionType, }; const choices = [defaultChoice]; if (existingFunctions) { choices.push({ name: 'Use a Lambda function already added in the current Amplify project', value: 'projectFunction', }); } const paths = answers.paths; let addAnotherPath: boolean; do { let pathName: string; let isPathValid: boolean; do { pathName = await prompter.input('Provide a path (e.g., /book/{isbn}):', { initial: currentPath ? currentPath.name : '/items', validate: validatePathName, }); 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; } else { // The path provided by the user overlaps with another endpoint that they've stood up with API Gateway. // 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 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 functionType = await prompter.pick<'one', string>('Choose a Lambda source', choices, { initial: choices.indexOf(defaultChoice) }); let path = { name: pathName }; let lambda; do { lambda = await askLambdaSource(context, functionType, pathName, currentPath); } while (!lambda); const permissions = await askPermissions(context, answers, currentPath); path = { ...path, ...lambda, permissions }; paths[pathName] = path; if (currentPath) { break; } addAnotherPath = await prompter.confirmContinue('Do you want to add another path?'); } while (addAnotherPath); const { dependsOn, functionArns } = await findDependsOn(paths); return { paths, dependsOn, resourceName: answers.resourceName, functionArns }; } async function findDependsOn(paths: Record<string, any>[]) { // go thru all paths and add lambdaFunctions to dependsOn and functionArns uniquely const dependsOn = []; const functionArns = []; 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: path.lambdaFunction, attributes: ['Name', 'Arn'], }); } } if (!functionArns.find((func) => func.lambdaFunction === path.lambdaFunction)) { functionArns.push({ lambdaFunction: path.lambdaFunction, lambdaArn: path.lambdaArn, }); } if (path?.permissions?.groups) { const userPoolGroups = Object.keys(path.permissions.groups); if (userPoolGroups.length > 0) { // Get auth resource name const authResourceName = getAuthResourceName(); if (!dependsOn.find((resource) => resource.resourceName === authResourceName)) { dependsOn.push({ category: 'auth', resourceName: authResourceName, attributes: ['UserPoolId'], }); } userPoolGroups.forEach((group) => { if (!dependsOn.find((resource) => resource.attributes[0] === `${group}GroupRole`)) { dependsOn.push({ category: 'auth', resourceName: 'userPoolGroups', attributes: [`${group}GroupRole`], }); } }); } } } return { dependsOn, functionArns }; } function getAuthResourceName(): string { const meta = stateManager.getMeta(); const authResources = (Object.entries(meta?.auth) || []).filter( ([_, resource]: [key: string, resource: Record<string, any>]) => resource.service === AmplifySupportedService.COGNITO, ); if (authResources.length === 0) { throw new Error('No auth resource found. Add it using amplify add auth'); } const [authResourceName] = authResources[0]; return authResourceName; } function functionsExist() { const meta = stateManager.getMeta(); if (!meta.function) { return false; } const functionResources = meta.function; const lambdaFunctions = []; Object.keys(functionResources).forEach((resourceName) => { if (functionResources[resourceName].service === AmplifySupportedService.LAMBDA) { lambdaFunctions.push(resourceName); } }); if (lambdaFunctions.length === 0) { return false; } return true; } async function askLambdaSource(context: $TSContext, functionType: string, path: string, currentPath?: ApigwPath) { switch (functionType) { case 'arn': return askLambdaArn(context, currentPath); case 'projectFunction': return askLambdaFromProject(currentPath); case 'newFunction': return newLambdaFunction(context as any, path); default: throw new Error('Type not supported'); } } async function newLambdaFunction(context: $TSContext, path: string) { let params = { functionTemplate: { parameters: { path, expressPath: formatCFNPathParamsForExpressJs(path), }, }, }; const resourceName = await context.amplify.invokePluginMethod(context, AmplifyCategories.FUNCTION, undefined, 'add', [ context, 'awscloudformation', AmplifySupportedService.LAMBDA, params, ]); printer.success('Succesfully added the Lambda function locally'); return { lambdaFunction: resourceName }; } async function askLambdaFromProject(currentPath?: ApigwPath) { const meta = stateManager.getMeta(); const lambdaFunctions = []; Object.keys(meta?.function || {}).forEach((resourceName) => { if (meta.function[resourceName].service === AmplifySupportedService.LAMBDA) { lambdaFunctions.push(resourceName); } }); 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 }; } async function askLambdaArn(context: $TSContext, currentPath?: ApigwPath) { const lambdaFunctions = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getLambdaFunctions'); const lambdaOptions = lambdaFunctions.map((lambdaFunction) => ({ value: lambdaFunction.FunctionArn, name: `${lambdaFunction.FunctionName} (${lambdaFunction.FunctionArn})`, })); if (lambdaOptions.length === 0) { printer.error('You do not have any Lambda functions configured for the selected Region'); return null; } const lambdaCloudOptionQuestion = { type: 'list', name: 'lambdaChoice', message: 'Select a Lambda function', choices: lambdaOptions, default: currentPath && currentPath.lambdaFunction ? `${currentPath.lambdaFunction}` : `${lambdaOptions[0].value}`, }; let lambdaOption; while (!lambdaOption) { try { lambdaOption = await inquirer.prompt([lambdaCloudOptionQuestion]); } catch (err) { printer.error('Select a Lambda Function'); } } const lambdaCloudOptionAnswer = lambdaFunctions.find((lambda) => lambda.FunctionArn === lambdaOption.lambdaChoice); return { lambdaArn: lambdaCloudOptionAnswer.FunctionArn, lambdaFunction: lambdaCloudOptionAnswer.FunctionName, }; } export async function migrate(context: $TSContext, projectPath: string, resourceName: string) { const apigwInputState = new ApigwInputState(context, resourceName); if (resourceName === ADMIN_QUERIES_NAME) { const meta = stateManager.getMeta(); const adminQueriesDependsOn = _.get(meta, [AmplifyCategories.API, ADMIN_QUERIES_NAME, 'dependsOn'], undefined); if (!adminQueriesDependsOn) { throw new Error('Failed to migrate Admin Queries API. Could not find expected information in amplify-meta.json.'); } const functionName = adminQueriesDependsOn.filter((dependency) => dependency.category === AmplifyCategories.FUNCTION)?.[0] ?.resourceName; const adminQueriesProps = { apiName: resourceName, authResourceName: getAuthResourceName(), functionName, dependsOn: adminQueriesDependsOn, }; return apigwInputState.migrateAdminQueries(adminQueriesProps); } return apigwInputState.migrateApigwResource(resourceName); } export function getIAMPolicies(resourceName: string, crudOptions: string[]) { let policy = {}; const actions = []; crudOptions.forEach((crudOption) => { switch (crudOption) { case CrudOperation.CREATE: actions.push('apigateway:POST', 'apigateway:PUT'); break; case CrudOperation.UPDATE: actions.push('apigateway:PATCH'); break; case CrudOperation.READ: actions.push('apigateway:GET', 'apigateway:HEAD', 'apigateway:OPTIONS'); break; case CrudOperation.DELETE: actions.push('apigateway:DELETE'); break; default: printer.info(`${crudOption} not supported`); } }); policy = { Effect: 'Allow', Action: actions, Resource: [ { 'Fn::Join': [ '', [ 'arn:aws:apigateway:', { Ref: 'AWS::Region', }, '::/restapis/', { Ref: `${category}${resourceName}ApiName`, }, '/*', ], ], }, ], }; const attributes = ['ApiName', 'ApiId']; return { policy, attributes }; } export const openConsole = async (context?: $TSContext) => { const amplifyMeta = stateManager.getMeta(); const categoryAmplifyMeta = amplifyMeta[category]; const { Region } = amplifyMeta.providers.awscloudformation; const restApis = Object.keys(categoryAmplifyMeta).filter((resourceName) => { const resource = categoryAmplifyMeta[resourceName]; return ( resource.output && (resource.service === serviceName || (resource.service === elasticContainerServiceName && resource.apiType === 'REST')) ); }); if (restApis) { let url; const selectedApi = await prompter.pick<'one', string>('Select the API', restApis); const selectedResource = categoryAmplifyMeta[selectedApi]; if (selectedResource.service === serviceName) { const { output: { ApiId }, } = selectedResource; url = `https://${Region}.console.aws.amazon.com/apigateway/home?region=${Region}#/apis/${ApiId}/resources/`; } else { // Elastic Container API const { output: { PipelineName, ServiceName, ClusterName }, } = selectedResource; const codePipeline = 'CodePipeline'; const elasticContainer = 'ElasticContainer'; const selectedConsole = await prompter.pick<'one', string>('Which console do 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 { printer.error('Option not available'); return; } } await open(url, { wait: false }); } else { printer.error('There are no REST APIs pushed to the cloud'); } };