@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
740 lines (620 loc) • 25.1 kB
text/typescript
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');
}
};