@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
1,173 lines (1,018 loc) • 38.1 kB
text/typescript
import * as path from 'path';
import { Duration, Expiration } from 'aws-cdk-lib';
import {
$TSContext,
exitOnNextTick,
FeatureFlags,
open,
pathManager,
ResourceAlreadyExistsError,
ResourceDoesNotExistError,
stateManager,
UnknownResourceTypeError,
getGraphQLTransformerAuthDocLink,
ApiCategoryFacade,
} from '@aws-amplify/amplify-cli-core';
import { UpdateApiRequest } from 'amplify-headless-interface';
import { printer, prompter } from '@aws-amplify/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 { 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 { checkAppsyncApiResourceMigration } from '../utils/check-appsync-api-migration';
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';
const providerName = 'awscloudformation';
const graphqlSchemaDir = path.join(rootAssetDir, 'graphql-schemas');
// keep in sync with ServiceName in amplify-category-function, but probably it will not change
const FunctionServiceNameLambdaFunction = 'Lambda';
const authProviderChoices = [
{
name: 'API key',
value: 'API_KEY',
},
{
name: 'Amazon Cognito User Pool',
value: 'AMAZON_COGNITO_USER_POOLS',
},
{
name: 'IAM',
value: 'AWS_IAM',
},
{
name: 'OpenID Connect',
value: 'OPENID_CONNECT',
},
];
const conflictResolutionHanlderChoices = [
{
name: 'Auto Merge',
value: 'AUTOMERGE',
},
{
name: 'Optimistic Concurrency',
value: 'OPTIMISTIC_CONCURRENCY',
},
{
name: 'Custom Lambda',
value: 'LAMBDA',
},
{
name: 'Learn More',
value: 'Learn More',
},
];
const blankSchemaFile = 'blank-schema.graphql';
const schemaTemplatesV1 = [
{
name: 'Single object with fields (e.g., “Todo” with ID, name, description)',
value: 'single-object-schema.graphql',
},
{
name: 'One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)',
value: 'many-relationship-schema.graphql',
},
{
name: 'Objects with fine-grained access control (e.g., a project management app with owner-based authorization)',
value: 'single-object-auth-schema.graphql',
},
{
name: 'Blank Schema',
value: blankSchemaFile,
},
];
const schemaTemplatesV2 = [
{
name: 'Single object with fields (e.g., “Todo” with ID, name, description)',
value: 'single-object-schema-v2.graphql',
},
{
name: 'One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)',
value: 'many-relationship-schema-v2.graphql',
},
{
name: 'Objects with fine-grained access control (e.g., a project management app with owner-based authorization)',
value: 'single-object-auth-schema-v2.graphql',
},
{
name: 'Blank Schema',
value: blankSchemaFile,
},
];
export const openConsole = async (context: $TSContext) => {
const amplifyMeta = stateManager.getMeta();
const categoryAmplifyMeta = amplifyMeta[category];
const { Region } = amplifyMeta.providers[providerName];
const graphQLApis = Object.keys(categoryAmplifyMeta).filter((resourceName) => {
const resource = categoryAmplifyMeta[resourceName];
return (
resource.output &&
(resource.service === serviceName || (resource.service === elasticContainerServiceName && resource.apiType === 'GRAPHQL'))
);
});
if (graphQLApis) {
let url;
let selectedApi = graphQLApis[0];
if (graphQLApis.length > 1) {
({ selectedApi } = await inquirer.prompt({
type: 'list',
name: 'selectedApi',
choices: graphQLApis,
message: 'Please select the API',
}));
}
const selectedResource = categoryAmplifyMeta[selectedApi];
if (selectedResource.service === serviceName) {
const {
output: { GraphQLAPIIdOutput },
} = selectedResource;
const appId = amplifyMeta.providers[providerName].AmplifyAppId;
if (!appId) {
throw new Error('Missing AmplifyAppId in amplify-meta.json');
}
url = `https://console.aws.amazon.com/appsync/home?region=${Region}#/${GraphQLAPIIdOutput}/v1/queries`;
const providerPlugin = await import(context.amplify.getProviderPlugins(context)[providerName]);
const { isAdminApp, region } = await providerPlugin.isAmplifyAdminApp(appId);
if (isAdminApp) {
if (region !== Region) {
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;
url = `${baseUrl}/admin/${appId}/${envName}/datastore`;
}
} else {
// Elastic Container API
const {
output: { PipelineName, ServiceName, ClusterName },
} = selectedResource;
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,
},
],
});
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('AppSync API is not pushed in the cloud.');
}
};
export const serviceApiInputWalkthrough = async (context: $TSContext, serviceMetadata) => {
let continuePrompt = false;
let authConfig;
let defaultAuthType;
let resolverConfig;
const { amplify } = context;
const { inputs } = serviceMetadata;
const allDefaultValues = getAllDefaults(amplify.getProjectDetails());
let resourceAnswers = {};
resourceAnswers[inputs[1].key] = allDefaultValues[inputs[1].key];
resourceAnswers[inputs[0].key] = resourceAnswers[inputs[1].key];
//
// Default authConfig - API Key (expires in 7 days)
//
authConfig = {
defaultAuthentication: {
apiKeyConfig: {
apiKeyExpirationDays: 7,
},
authenticationType: 'API_KEY',
},
additionalAuthenticationProviders: [],
};
//
// Repeat prompt until user selects Continue
//
while (!continuePrompt) {
const getAuthModeChoice = async () => {
if (authConfig.defaultAuthentication.authenticationType === 'API_KEY') {
return `${
authProviderChoices.find((choice) => choice.value === authConfig.defaultAuthentication.authenticationType).name
} (default, expiration time: ${authConfig.defaultAuthentication.apiKeyConfig.apiKeyExpirationDays} days from now)`;
}
return `${authProviderChoices.find((choice) => choice.value === authConfig.defaultAuthentication.authenticationType).name} (default)`;
};
const getAdditionalAuthModeChoices = async () => {
let additionalAuthModesText = '';
authConfig.additionalAuthenticationProviders.map(async (authMode) => {
additionalAuthModesText += `, ${authProviderChoices.find((choice) => choice.value === authMode.authenticationType).name}`;
});
return additionalAuthModesText;
};
const basicInfoQuestionChoices = [];
basicInfoQuestionChoices.push({
name: chalk`{bold Name:} ${resourceAnswers[inputs[1].key]}`,
value: 'API_NAME',
});
basicInfoQuestionChoices.push({
name: chalk`{bold Authorization modes:} ${await getAuthModeChoice()}${await getAdditionalAuthModeChoices()}`,
value: 'API_AUTH_MODE',
});
basicInfoQuestionChoices.push({
name: chalk`{bold Conflict detection (required for DataStore):} ${resolverConfig?.project ? 'Enabled' : 'Disabled'}`,
value: 'CONFLICT_DETECTION',
});
if (resolverConfig?.project) {
basicInfoQuestionChoices.push({
name: chalk`{bold Conflict resolution strategy:} ${
conflictResolutionHanlderChoices.find((x) => x.value === resolverConfig.project.ConflictHandler).name
}`,
value: 'CONFLICT_STRATEGY',
});
}
basicInfoQuestionChoices.push({
name: 'Continue',
value: 'CONTINUE',
});
const basicInfoQuestion = {
type: 'list',
name: 'basicApiSettings',
message: 'Here is the GraphQL API that we will create. Select a setting to edit or continue',
default: 'CONTINUE',
choices: basicInfoQuestionChoices,
};
let { basicApiSettings } = await inquirer.prompt([basicInfoQuestion]);
switch (basicApiSettings) {
case 'API_NAME': {
const resourceQuestions = [
{
type: inputs[1].type,
name: inputs[1].key,
message: inputs[1].question,
validate: amplify.inputValidation(inputs[1]),
default: () => {
const defaultValue = allDefaultValues[inputs[1].key];
return defaultValue;
},
},
];
// API name question
resourceAnswers = await inquirer.prompt(resourceQuestions);
resourceAnswers[inputs[0].key] = resourceAnswers[inputs[1].key];
allDefaultValues[inputs[1].key] = resourceAnswers[inputs[1].key];
break;
}
case 'API_AUTH_MODE':
// Ask additonal questions
({ authConfig, defaultAuthType } = await askDefaultAuthQuestion(context));
({ authConfig } = await askAdditionalQuestions(context, authConfig, defaultAuthType));
break;
case 'CONFLICT_DETECTION':
resolverConfig = await askResolverConflictQuestion(context, resolverConfig);
break;
case 'CONFLICT_STRATEGY':
resolverConfig = await askResolverConflictHandlerQuestion(context);
break;
case 'CONTINUE':
continuePrompt = true;
break;
}
}
return {
answers: resourceAnswers,
output: {
authConfig,
},
resolverConfig,
};
};
const updateApiInputWalkthrough = async (context: $TSContext, project: Record<string, any>, resolverConfig, modelTypes) => {
let authConfig;
let defaultAuthType;
const updateChoices = [
{
name: 'Authorization modes',
value: 'AUTH_MODE',
},
];
// check if DataStore is enabled for the entire API
if (project.config && !_.isEmpty(project.config.ResolverConfig)) {
updateChoices.push({
name: 'Conflict resolution strategy',
value: 'CONFLICT_STRATEGY',
});
updateChoices.push({
name: 'Disable conflict detection',
value: 'DISABLE_CONFLICT',
});
} else {
updateChoices.push({
name: 'Enable conflict detection (required for DataStore)',
value: 'ENABLE_CONFLICT',
});
}
const updateOptionQuestion = {
type: 'list',
name: 'updateOption',
message: 'Select a setting to edit',
choices: updateChoices,
};
const { updateOption } = await inquirer.prompt([updateOptionQuestion]);
if (updateOption === 'ENABLE_CONFLICT') {
resolverConfig = await askResolverConflictHandlerQuestion(context, modelTypes);
} else if (updateOption === 'DISABLE_CONFLICT') {
resolverConfig = {};
} else if (updateOption === 'AUTH_MODE') {
({ authConfig, defaultAuthType } = await askDefaultAuthQuestion(context));
authConfig = await askAdditionalAuthQuestions(context, authConfig, defaultAuthType);
} else if (updateOption === 'CONFLICT_STRATEGY') {
resolverConfig = await askResolverConflictHandlerQuestion(context, modelTypes);
}
return {
authConfig,
resolverConfig,
};
};
export const serviceWalkthrough = async (context: $TSContext, serviceMetadata: Record<string, any>) => {
const resourceName = resourceAlreadyExists();
const transformerVersion = await ApiCategoryFacade.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.';
printer.warn(errMessage);
await context.usageData.emitError(new ResourceAlreadyExistsError(errMessage));
exitOnNextTick(0);
}
const { amplify } = context;
const { inputs } = serviceMetadata;
const basicInfoAnswers = await serviceApiInputWalkthrough(context, serviceMetadata);
let schemaContent = '';
let askToEdit = true;
// Schema template selection
const schemaTemplateOptions = transformerVersion === 2 ? schemaTemplatesV2 : schemaTemplatesV1;
const templateSelectionQuestion = {
type: inputs[4].type,
name: inputs[4].key,
message: inputs[4].question,
choices: schemaTemplateOptions.filter(templateSchemaFilter(basicInfoAnswers.output.authConfig)),
validate: amplify.inputValidation(inputs[4]),
};
const { templateSelection } = await inquirer.prompt(templateSelectionQuestion);
const schemaFilePath = path.join(graphqlSchemaDir, templateSelection);
schemaContent += transformerVersion === 2 ? defineGlobalSandboxMode(getGraphQLTransformerAuthDocLink(transformerVersion)) : '';
schemaContent += fs.readFileSync(schemaFilePath, 'utf8');
return {
...basicInfoAnswers,
noCfnFile: true,
schemaContent,
askToEdit,
};
};
export const updateWalkthrough = async (context: $TSContext): Promise<UpdateApiRequest> => {
const { allResources } = await context.amplify.getResourceStatus();
let resourceDir;
let resourceName;
let resource;
let authConfig;
const resources = allResources.filter((resource) => resource.service === 'AppSync');
await addLambdaAuthorizerChoice(context);
// There can only be one appsync resource
if (resources.length > 0) {
resource = resources[0];
if (resource.providerPlugin !== providerName) {
// TODO: Move message string to seperate file
throw new Error(
`The selected resource is not managed using AWS Cloudformation. Please use the AWS AppSync Console to make updates to your API - ${resource.resourceName}`,
);
}
({ resourceName } = resource);
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.';
printer.error(errMessage);
await context.usageData.emitError(new ResourceDoesNotExistError(errMessage));
exitOnNextTick(0);
}
// migrate API project
await checkAppsyncApiResourceMigration(context, resourceName, true);
// Get models
const project = await readProjectConfiguration(resourceDir);
let resolverConfig = project.config.ResolverConfig;
await displayApiInformation(context, resource, project);
// Check for common errors
const directiveMap = collectDirectivesByTypeNames(project.schema);
let modelTypes = [];
if (directiveMap.types) {
Object.keys(directiveMap.types).forEach((type) => {
if (directiveMap.types[type].includes('model')) {
modelTypes.push(type);
}
});
}
({ authConfig, resolverConfig } = await updateApiInputWalkthrough(context, project, resolverConfig, modelTypes));
return {
version: 1,
serviceModification: {
serviceName: 'AppSync',
defaultAuthType: authConfigToAppSyncAuthType(authConfig ? authConfig.defaultAuthentication : undefined),
additionalAuthTypes:
authConfig && authConfig.additionalAuthenticationProviders
? authConfig.additionalAuthenticationProviders.map(authConfigToAppSyncAuthType)
: undefined,
conflictResolution: resolverConfigToConflictResolution(resolverConfig),
},
};
};
async function displayApiInformation(context: $TSContext, resource: Record<string, any>, project: Record<string, any>) {
let authModes: string[] = [];
authModes.push(
`- Default: ${await displayAuthMode(context, resource, resource.output.authConfig.defaultAuthentication.authenticationType)}`,
);
await resource.output.authConfig.additionalAuthenticationProviders?.map(async (authMode) => {
authModes.push(`- ${await displayAuthMode(context, resource, authMode.authenticationType)}`);
});
printer.info('');
printer.info('General information');
printer.info('- Name: '.concat(resource.resourceName));
if (resource?.output?.GraphQLAPIEndpointOutput) {
printer.info(`- API endpoint: ${resource?.output?.GraphQLAPIEndpointOutput}`);
}
printer.info('');
printer.info('Authorization modes');
authModes.forEach((authMode) => printer.info(authMode));
printer.info('');
printer.info('Conflict detection (required for DataStore)');
if (project.config && !_.isEmpty(project.config.ResolverConfig)) {
printer.info(
`- Conflict resolution strategy: ${
conflictResolutionHanlderChoices.find((choice) => choice.value === project.config.ResolverConfig.project.ConflictHandler).name
}`,
);
} else {
printer.info('- Disabled');
}
printer.info('');
}
async function displayAuthMode(context: $TSContext, resource: Record<string, any>, authMode: string) {
if (authMode === 'API_KEY' && resource.output.GraphQLAPIKeyOutput) {
let { apiKeys } = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getGraphQLApiKeys', {
apiId: resource.output.GraphQLAPIIdOutput,
});
let apiKeyExpires = apiKeys.find((key) => key.id == resource.output.GraphQLAPIKeyOutput)?.expires;
if (!apiKeyExpires) {
return authProviderChoices.find((choice) => choice.value === authMode).name;
}
let apiKeyExpiresDate = new Date(apiKeyExpires * 1000);
return `${authProviderChoices.find((choice) => choice.value === authMode).name} expiring ${apiKeyExpiresDate}: ${
resource.output.GraphQLAPIKeyOutput
}`;
}
return authProviderChoices.find((choice) => choice.value === authMode).name;
}
async function askAdditionalQuestions(context: $TSContext, authConfig, defaultAuthType, modelTypes?) {
authConfig = await askAdditionalAuthQuestions(context, authConfig, defaultAuthType);
return { authConfig };
}
async function askResolverConflictQuestion(context: $TSContext, resolverConfig, modelTypes?) {
let resolverConfigResponse: Record<string, any> = {};
if (await context.prompt.confirm('Enable conflict detection?', !resolverConfig?.project)) {
resolverConfigResponse = await askResolverConflictHandlerQuestion(context, modelTypes);
}
return resolverConfigResponse;
}
async function askResolverConflictHandlerQuestion(context: $TSContext, modelTypes?) {
let resolverConfig: Record<string, any> = {};
const askConflictResolutionStrategy = async (msg) => {
let conflictResolutionStrategy;
do {
const conflictResolutionQuestion: ListQuestion = {
type: 'list',
name: 'conflictResolutionStrategy',
message: msg,
default: 'AUTOMERGE',
choices: conflictResolutionHanlderChoices,
};
if (conflictResolutionStrategy === 'Learn More') {
conflictResolutionQuestion.prefix = dataStoreLearnMore;
}
({ conflictResolutionStrategy } = await inquirer.prompt([conflictResolutionQuestion]));
} while (conflictResolutionStrategy === 'Learn More');
let syncConfig: Record<string, any> = {
ConflictHandler: conflictResolutionStrategy,
ConflictDetection: 'VERSION',
};
if (conflictResolutionStrategy === 'LAMBDA') {
const { newFunction, lambdaFunctionName } = await askSyncFunctionQuestion();
syncConfig.LambdaConflictHandler = {
name: lambdaFunctionName,
new: newFunction,
};
}
return syncConfig;
};
resolverConfig.project = await askConflictResolutionStrategy('Select the default resolution strategy');
// Ask for per-model resolver override setting
if (modelTypes && modelTypes.length > 0) {
if (await context.prompt.confirm('Do you want to override default per model settings?', false)) {
const modelTypeQuestion = {
type: 'checkbox',
name: 'selectedModelTypes',
message: 'Select the models from below:',
choices: modelTypes,
};
const { selectedModelTypes } = await inquirer.prompt([modelTypeQuestion]);
if (selectedModelTypes.length > 0) {
resolverConfig.models = {};
for (const modelType of selectedModelTypes) {
resolverConfig.models[modelType] = await askConflictResolutionStrategy(`Select the resolution strategy for ${modelType} model`);
}
}
}
}
return resolverConfig;
}
async function askSyncFunctionQuestion() {
const syncLambdaQuestion = {
type: 'list',
name: 'syncLambdaAnswer',
message: 'Select from the options below',
choices: [
{
name: 'Create a new Lambda Function',
value: 'NEW',
},
{
name: 'Existing Lambda Function',
value: 'EXISTING',
},
],
};
const { syncLambdaAnswer } = await inquirer.prompt([syncLambdaQuestion]);
let lambdaFunctionName;
const newFunction = syncLambdaAnswer === 'NEW';
if (!newFunction) {
const syncLambdaNameQuestion = {
type: 'input',
name: 'lambdaFunctionName',
message: 'Enter lambda function name',
validate: (val) => !!val,
};
({ lambdaFunctionName } = await inquirer.prompt([syncLambdaNameQuestion]));
}
return { newFunction, lambdaFunctionName };
}
async function addLambdaAuthorizerChoice(context: $TSContext) {
const transformerVersion = await ApiCategoryFacade.getTransformerVersion(context);
if (transformerVersion === 2 && !authProviderChoices.some((choice) => choice.value == 'AWS_LAMBDA')) {
authProviderChoices.push({
name: 'Lambda',
value: 'AWS_LAMBDA',
});
}
}
async function askDefaultAuthQuestion(context: $TSContext) {
await addLambdaAuthorizerChoice(context);
const currentAuthConfig = getAppSyncAuthConfig(stateManager.getMeta());
const currentDefaultAuth =
currentAuthConfig && currentAuthConfig.defaultAuthentication ? currentAuthConfig.defaultAuthentication.authenticationType : undefined;
const defaultAuthTypeQuestion = {
type: 'list',
name: 'defaultAuthType',
message: 'Choose the default authorization type for the API',
choices: authProviderChoices,
default: currentDefaultAuth,
};
const { defaultAuthType } = await inquirer.prompt([defaultAuthTypeQuestion]);
// Get default auth configured
const defaultAuth = await askAuthQuestions(defaultAuthType, context, false, currentAuthConfig?.defaultAuthentication);
return {
authConfig: {
defaultAuthentication: defaultAuth,
},
defaultAuthType,
};
}
export async function askAdditionalAuthQuestions(context: $TSContext, authConfig: Record<string, any>, defaultAuthType) {
const currentAuthConfig = getAppSyncAuthConfig(stateManager.getMeta());
authConfig.additionalAuthenticationProviders = [];
if (await context.prompt.confirm('Configure additional auth types?')) {
// Get additional auth configured
const remainingAuthProviderChoices = authProviderChoices.filter((p) => p.value !== defaultAuthType);
const currentAdditionalAuth = (
(currentAuthConfig && currentAuthConfig.additionalAuthenticationProviders
? currentAuthConfig.additionalAuthenticationProviders
: []) as any[]
).map((authProvider) => authProvider.authenticationType);
const additionalProvidersQuestion: CheckboxQuestion = {
type: 'checkbox',
name: 'authType',
message: 'Choose the additional authorization types you want to configure for the API',
choices: remainingAuthProviderChoices,
default: currentAdditionalAuth,
};
const additionalProvidersAnswer = await inquirer.prompt([additionalProvidersQuestion]);
for (const authProvider of additionalProvidersAnswer.authType) {
const config = await askAuthQuestions(
authProvider,
context,
true,
currentAuthConfig?.additionalAuthenticationProviders?.find((authSetting) => authSetting.authenticationType == authProvider),
);
authConfig.additionalAuthenticationProviders.push(config);
}
} else {
authConfig.additionalAuthenticationProviders = (currentAuthConfig?.additionalAuthenticationProviders || []).filter(
(p) => p.authenticationType !== defaultAuthType,
);
}
return authConfig;
}
export async function askAuthQuestions(authType: string, context: $TSContext, printLeadText = false, authSettings) {
if (authType === 'AMAZON_COGNITO_USER_POOLS') {
if (printLeadText) {
printer.info('Cognito UserPool configuration');
}
const userPoolConfig = await askUserPoolQuestions(context);
return userPoolConfig;
}
if (authType === 'API_KEY') {
if (printLeadText) {
printer.info('API key configuration');
}
const apiKeyConfig = await askApiKeyQuestions(authSettings);
return apiKeyConfig;
}
if (authType === 'AWS_IAM') {
return {
authenticationType: 'AWS_IAM',
};
}
if (authType === 'OPENID_CONNECT') {
if (printLeadText) {
printer.info('OpenID Connect configuration');
}
const openIDConnectConfig = await askOpenIDConnectQuestions(authSettings);
return openIDConnectConfig;
}
if (authType === 'AWS_LAMBDA') {
if (printLeadText) {
context.print.info('Lambda Authorizer configuration');
}
const lambdaConfig = await askLambdaQuestion(context);
return lambdaConfig;
}
const errMessage = `Unknown authType: ${authType}`;
printer.error(errMessage);
await context.usageData.emitError(new UnknownResourceTypeError(errMessage));
exitOnNextTick(1);
}
async function askUserPoolQuestions(context: $TSContext) {
let authResourceName = checkIfAuthExists();
if (!authResourceName) {
authResourceName = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'add', [context, true]);
} else {
printer.info('Use a Cognito user pool configured as a part of this project.');
}
// Added resources are prefixed with auth
authResourceName = `auth${authResourceName}`;
return {
authenticationType: 'AMAZON_COGNITO_USER_POOLS',
userPoolConfig: {
userPoolId: authResourceName,
},
};
}
export async function askApiKeyQuestions(authSettings: Record<string, any> = undefined) {
let defaultValues = {
apiKeyExpirationDays: 7,
description: '',
};
Object.assign(defaultValues, authSettings?.apiKeyConfig);
const apiKeyQuestions = [
{
type: 'input',
name: 'description',
message: 'Enter a description for the API key:',
default: defaultValues.description,
},
{
type: 'input',
name: 'apiKeyExpirationDays',
message: 'After how many days from now the API key should expire (1-365):',
default: defaultValues.apiKeyExpirationDays,
validate: validateDays,
// adding filter to ensure parsing input as int -> https://github.com/SBoudrias/Inquirer.js/issues/866
filter: (value) => {
const val = parseInt(value, 10);
if (isNaN(val) || val <= 0 || val > 365) {
return value;
}
return val;
},
},
];
const apiKeyConfig: Record<string, any> = {};
for (const apiKeyQuestion of apiKeyQuestions) {
apiKeyConfig[apiKeyQuestion.name] = await prompter.input(apiKeyQuestion.message, { initial: apiKeyQuestion.default as string });
}
const apiKeyExpirationDaysNum = Number(apiKeyConfig.apiKeyExpirationDays);
apiKeyConfig.apiKeyExpirationDate = Expiration.after(Duration.days(apiKeyExpirationDaysNum)).date;
apiKeyConfig.apiKeyExpirationDays = apiKeyExpirationDaysNum;
return {
authenticationType: 'API_KEY',
apiKeyConfig,
};
}
async function askOpenIDConnectQuestions(authSettings: Record<string, any>) {
let defaultValues = {
authTTL: undefined,
clientId: undefined,
iatTTL: undefined,
issuerUrl: undefined,
name: undefined,
};
Object.assign(defaultValues, authSettings?.openIDConnectConfig);
const openIDConnectQuestions = [
{
type: 'input',
name: 'name',
message: 'Enter a name for the OpenID Connect provider:',
default: defaultValues.name,
},
{
type: 'input',
name: 'issuerUrl',
message: 'Enter the OpenID Connect provider domain (Issuer URL):',
validate: validateIssuerUrl,
default: defaultValues.issuerUrl,
},
{
type: 'input',
name: 'clientId',
message: 'Enter the Client Id from your OpenID Client Connect application (optional):',
default: defaultValues.clientId,
},
{
type: 'input',
name: 'iatTTL',
message: 'Enter the number of milliseconds a token is valid after being issued to a user:',
validate: validateTTL,
default: defaultValues.iatTTL,
},
{
type: 'input',
name: 'authTTL',
message: 'Enter the number of milliseconds a token is valid after being authenticated:',
validate: validateTTL,
default: defaultValues.authTTL,
},
];
const openIDConnectConfig = await inquirer.prompt(openIDConnectQuestions);
return {
authenticationType: 'OPENID_CONNECT',
openIDConnectConfig,
};
}
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) {
return 'Number of days must be between 1 and 365.';
}
return true;
}
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,
);
if (!isValid) {
return 'The value must be a valid URI with a trailing forward slash. HTTPS must be used instead of HTTP unless you are using localhost.';
}
return true;
}
function validateTTL(input: string) {
const isValid = /^\d+$/.test(input);
if (!isValid) {
return 'The value must be a number.';
}
return true;
}
function resourceAlreadyExists() {
const meta = stateManager.getMeta();
let resourceName;
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: $TSContext) => {
await context.amplify.executeProviderUtils(context, 'awscloudformation', 'compileSchema', {
forceCompile: true,
migrate: true,
});
};
export const getIAMPolicies = (resourceName: string, operations: string[]) => {
let policy: Record<string, any> = {};
const resources = [];
const actions = [];
if (!FeatureFlags.getBoolean('appSync.generateGraphQLPermissions')) {
operations.forEach((crudOption) => {
switch (crudOption) {
case 'create':
actions.push('appsync:Create*', 'appsync:StartSchemaCreation', 'appsync:GraphQL');
resources.push(buildPolicyResource(resourceName, '/*'));
break;
case 'update':
actions.push('appsync:Update*');
break;
case 'read':
actions.push('appsync:Get*', 'appsync:List*');
break;
case 'delete':
actions.push('appsync:Delete*');
break;
default:
printer.info(`${crudOption} not supported`);
}
});
resources.push(buildPolicyResource(resourceName, null));
} else {
actions.push('appsync:GraphQL');
operations.forEach((operation) => resources.push(buildPolicyResource(resourceName, `/types/${operation}/*`)));
}
policy = {
Effect: 'Allow',
Action: actions,
Resource: resources,
};
const attributes = ['GraphQLAPIIdOutput', 'GraphQLAPIEndpointOutput'];
if (authConfigHasApiKey(getAppSyncAuthConfig(stateManager.getMeta()))) {
attributes.push('GraphQLAPIKeyOutput');
}
return { policy, attributes };
};
const buildPolicyResource = (resourceName: string, path: string | null) => {
return {
'Fn::Join': [
'',
[
'arn:aws:appsync:',
{ Ref: 'AWS::Region' },
':',
{ Ref: 'AWS::AccountId' },
':apis/',
{
Ref: `${category}${resourceName}GraphQLAPIIdOutput`,
},
...(path ? [path] : []),
],
],
};
};
const templateSchemaFilter = (authConfig) => {
const authIncludesCognito = getAuthTypes(authConfig).includes('AMAZON_COGNITO_USER_POOLS');
return (templateOption: ListChoiceOptions): boolean =>
authIncludesCognito ||
templateOption.name !== 'Objects with fine-grained access control (e.g., a project management app with owner-based authorization)';
};
const getAuthTypes = (authConfig) => {
const additionalAuthTypes = (authConfig.additionalAuthenticationProviders || [])
.map((provider) => provider.authenticationType)
.filter((t) => !!t);
const uniqueAuthTypes = new Set([...additionalAuthTypes, authConfig.defaultAuthentication.authenticationType]);
return [...uniqueAuthTypes.keys()];
};
async function askLambdaQuestion(context) {
const existingFunctions = functionsExist(context);
const choices = [
{
name: 'Create a new Lambda function',
value: 'newFunction',
},
];
if (existingFunctions) {
choices.push({
name: 'Use a Lambda function already added in the current Amplify project',
value: 'projectFunction',
});
}
let defaultFunctionType = 'newFunction';
const lambdaAnswer = await inquirer.prompt({
name: 'functionType',
type: 'list',
message: 'Choose a Lambda authorization function',
choices,
default: defaultFunctionType,
});
const { lambdaFunction } = await askLambdaSource(context, lambdaAnswer.functionType);
const { ttlSeconds } = await inquirer.prompt({
type: 'input',
name: 'ttlSeconds',
message: 'How long should the authorization response be cached in seconds?',
validate: validateTTL,
default: '300',
});
const lambdaAuthorizerConfig = {
lambdaFunction,
ttlSeconds,
};
return {
authenticationType: 'AWS_LAMBDA',
lambdaAuthorizerConfig,
};
}
function functionsExist(context: $TSContext): boolean {
const functionResources = context.amplify.getProjectDetails().amplifyMeta.function;
if (!functionResources) {
return false;
}
const lambdaFunctions = [];
Object.keys(functionResources).forEach((resourceName) => {
if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) {
lambdaFunctions.push(resourceName);
}
});
return lambdaFunctions.length !== 0;
}
async function askLambdaSource(context: $TSContext, functionType: string) {
switch (functionType) {
case 'projectFunction':
return await askLambdaFromProject(context);
case 'newFunction':
return await newLambdaFunction(context);
default:
throw new Error(`Type ${functionType} not supported`);
}
}
async function newLambdaFunction(context: $TSContext) {
const resourceName = await createLambdaAuthorizerFunction(context);
return { lambdaFunction: resourceName };
}
async function askLambdaFromProject(context: $TSContext) {
const functionResources = context.amplify.getProjectDetails().amplifyMeta.function;
const lambdaFunctions = [];
Object.keys(functionResources).forEach((resourceName) => {
if (functionResources[resourceName].service === FunctionServiceNameLambdaFunction) {
lambdaFunctions.push(resourceName);
}
});
const answer = await inquirer.prompt({
name: 'lambdaFunction',
type: 'list',
message: 'Choose one of the Lambda functions',
choices: lambdaFunctions,
default: lambdaFunctions[0],
});
await context.amplify.invokePluginMethod(context, 'function', undefined, 'addAppSyncInvokeMethodPermission', [answer.lambdaFunction]);
return { lambdaFunction: answer.lambdaFunction };
}
async function createLambdaAuthorizerFunction(context: $TSContext) {
const [shortId] = uuid().split('-');
const functionName = `graphQlLambdaAuthorizer${shortId}`;
const resourceName = await context.amplify.invokePluginMethod(context, 'function', undefined, 'add', [
context,
'awscloudformation',
FunctionServiceNameLambdaFunction,
{
functionName,
defaultRuntime: 'nodejs',
providerContext: {
provider: 'awscloudformation',
},
template: 'lambda-auth',
skipAdvancedSection: true,
skipNextSteps: true,
},
]);
context.print.success(`Successfully added ${resourceName} function locally`);
await context.amplify.invokePluginMethod(context, 'function', undefined, 'addAppSyncInvokeMethodPermission', [resourceName]);
return resourceName;
}