@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
485 lines (432 loc) • 19.1 kB
text/typescript
import path from 'path';
import {
constructSqlDirectiveDataSourceStrategies,
DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY,
DDB_DEFAULT_DATASOURCE_STRATEGY,
getDefaultStrategyNameForDbType,
getImportedRDSTypeFromStrategyDbType,
isDynamoDbType,
isSqlDbType,
UserDefinedSlot,
} from '@aws-amplify/graphql-transformer-core';
import {
AppSyncAuthConfiguration,
DataSourceStrategiesProvider,
ModelDataSourceStrategy,
ModelDataSourceStrategyDbType,
ModelDataSourceStrategySqlDbType,
RDSLayerMapping,
RDSSNSTopicMapping,
SQLLambdaModelDataSourceStrategy,
SqlDirectiveDataSourceStrategy,
SqlModelDataSourceDbConnectionConfig,
TransformerLog,
TransformerLogLevel,
VpcConfig,
} from '@aws-amplify/graphql-transformer-interfaces';
import * as fs from 'fs-extra';
import { ResourceConstants } from 'graphql-transformer-common';
import { sanityCheckProject } from 'graphql-transformer-core';
import _ from 'lodash';
import { executeTransform } from '@aws-amplify/graphql-transformer';
import { $TSContext, AmplifyCategories, AmplifySupportedService, JSONUtilities, pathManager } from '@aws-amplify/amplify-cli-core';
import { printer } from '@aws-amplify/amplify-prompts';
import { getHostVpc } from '@aws-amplify/graphql-schema-generator';
import fetch from 'node-fetch';
import {
getConnectionSecrets,
getExistingConnectionDbConnectionConfig,
getSecretsKey,
} from '../provider-utils/awscloudformation/utils/rds-resources/database-resources';
import { getAppSyncAPIName } from '../provider-utils/awscloudformation/utils/amplify-meta-utils';
import { checkForUnsupportedDirectives, containsSqlModelOrDirective } from '../provider-utils/awscloudformation/utils/rds-resources/utils';
import { isAuthModeUpdated } from './auth-mode-compare';
import { getAdminRoles, getIdentityPoolId, mergeUserConfigWithTransformOutput, writeDeploymentToDisk } from './utils';
import { generateTransformerOptions } from './transformer-options-v2';
import { TransformerProjectOptions } from './transformer-options-types';
import { DeploymentResources } from './cdk-compat/deployment-resources';
import { TransformManager } from './cdk-compat/transform-manager';
const PARAMETERS_FILENAME = 'parameters.json';
const SCHEMA_FILENAME = 'schema.graphql';
const SCHEMA_DIR_NAME = 'schema';
const PROVIDER_NAME = 'awscloudformation';
const USE_BETA_SQL_LAYER = 'use-beta-sql-layer';
/**
* Transform GraphQL Schema
*/
export const transformGraphQLSchemaV2 = async (context: $TSContext, options): Promise<DeploymentResources | undefined> => {
let resourceName: string;
const backEndDir = pathManager.getBackendDirPath();
const flags = context.parameters.options;
if (flags['no-gql-override']) {
return undefined;
}
let { resourceDir, parameters } = options;
const { forceCompile } = options;
// Compilation during the push step
const { resourcesToBeCreated, resourcesToBeUpdated, allResources } = await context.amplify.getResourceStatus(AmplifyCategories.API);
let resources = resourcesToBeCreated.concat(resourcesToBeUpdated);
// When build folder is missing include the API
// to be compiled without the backend/api/<api-name>/build
// cloud formation push will fail even if there is no changes in the GraphQL API
// https://github.com/aws-amplify/amplify-console/issues/10
const resourceNeedCompile = allResources
.filter((r) => !resources.includes(r))
.filter((r) => {
const buildDir = path.normalize(path.join(backEndDir, AmplifyCategories.API, r.resourceName, 'build'));
return !fs.pathExistsSync(buildDir);
});
resources = resources.concat(resourceNeedCompile);
if (forceCompile) {
resources = resources.concat(allResources);
}
resources = resources.filter((resource) => resource.service === 'AppSync');
if (!resourceDir) {
// There can only be one appsync resource
if (resources.length > 0) {
const resource = resources[0];
if (resource.providerPlugin !== PROVIDER_NAME) {
return undefined;
}
const { category } = resource;
({ resourceName } = resource);
resourceDir = path.normalize(path.join(backEndDir, category, resourceName));
} else {
// No appsync resource to update/add
return undefined;
}
}
const previouslyDeployedBackendDir = options.cloudBackendDirectory;
if (!previouslyDeployedBackendDir) {
if (resources.length > 0) {
const resource = resources[0];
if (resource.providerPlugin !== PROVIDER_NAME) {
return undefined;
}
}
}
const parametersFilePath = path.join(resourceDir, PARAMETERS_FILENAME);
if (!parameters && fs.pathExistsSync(parametersFilePath)) {
try {
parameters = JSONUtilities.readJson(parametersFilePath);
// OpenSearch Instance type support for x.y.search types
if (parameters[ResourceConstants.PARAMETERS.OpenSearchInstanceType]) {
parameters[ResourceConstants.PARAMETERS.OpenSearchInstanceType] = parameters[
ResourceConstants.PARAMETERS.OpenSearchInstanceType
].replace('.search', '.elasticsearch');
}
} catch (e) {
parameters = {};
}
}
let { authConfig }: { authConfig: AppSyncAuthConfiguration } = options;
if (_.isEmpty(authConfig) && !_.isEmpty(resources)) {
authConfig = await context.amplify.invokePluginMethod(
context,
AmplifyCategories.API,
AmplifySupportedService.APPSYNC,
'getAuthConfig',
[context, resources[0].resourceName],
);
// handle case where auth project is not migrated , if Auth not migrated above function will return empty Object
if (_.isEmpty(authConfig)) {
//
// If we don't have an authConfig from the caller, use it from the
// already read resources[0], which is an AppSync API.
//
if (resources[0].output.securityType) {
// Convert to multi-auth format if needed.
authConfig = {
defaultAuthentication: {
authenticationType: resources[0].output.securityType,
},
additionalAuthenticationProviders: [],
};
} else {
({ authConfig } = resources[0].output);
}
}
}
const buildDir = path.normalize(path.join(resourceDir, 'build'));
const schemaFilePath = path.normalize(path.join(resourceDir, SCHEMA_FILENAME));
const schemaDirPath = path.normalize(path.join(resourceDir, SCHEMA_DIR_NAME));
// If it is a dry run, don't create the build folder as it could make a follow-up command
// to not to trigger a build, hence a corrupt deployment.
if (!options.dryRun) {
fs.ensureDirSync(buildDir);
}
// The buildConfig.projectConfig returned by `generateTransformerOptions` is not actually compatible with DataSourceStrategiesProvider. We
// will correct that in buildAPIProject.
const buildConfig: TransformerProjectOptions = await generateTransformerOptions(context, options);
if (!buildConfig) {
return undefined;
}
const transformerOutput = await buildAPIProject(context, buildConfig);
printer.success(`GraphQL schema compiled successfully.\n\nEdit your schema at ${schemaFilePath} or \
place .graphql files in a directory at ${schemaDirPath}`);
if (isAuthModeUpdated(options)) {
parameters.AuthModeLastUpdated = new Date();
}
if (!options.dryRun) {
JSONUtilities.writeJson(parametersFilePath, parameters);
}
return transformerOutput;
};
const getAuthenticationTypesForAuthConfig = (authConfig?: AppSyncAuthConfiguration): (string | undefined)[] =>
[authConfig?.defaultAuthentication, ...(authConfig?.additionalAuthenticationProviders ?? [])].map(
(authConfigEntry) => authConfigEntry?.authenticationType,
);
const hasIamAuth = (authConfig?: AppSyncAuthConfiguration): boolean =>
getAuthenticationTypesForAuthConfig(authConfig).some((authType) => authType === 'AWS_IAM');
const hasUserPoolAuth = (authConfig?: AppSyncAuthConfiguration): boolean =>
getAuthenticationTypesForAuthConfig(authConfig).some((authType) => authType === 'AMAZON_COGNITO_USER_POOLS');
/**
* Given an array of DataSourceType shapes from the Gen1 CLI import flow, finds the single SQL database type. Throws an error if more than
* one SQL database type is detected.
*/
const getSqlDbTypeFromDataSourceTypes = (
dataSourceTypes: Array<{
dbType: ModelDataSourceStrategyDbType;
provisionDB: boolean;
provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE';
}>,
): ModelDataSourceStrategySqlDbType | undefined => {
const dbTypes = Object.values(dataSourceTypes)
.map((dsType) => dsType.dbType)
.filter(isSqlDbType);
if (dbTypes.length === 0) {
return undefined;
}
if (new Set(dbTypes).size > 1) {
throw new Error(`Multiple imported SQL datasource types ${Array.from(dbTypes)} are detected. Only one type is supported.`);
}
return dbTypes[0];
};
/**
* The `projectConfig` argument to `fixUpDataSourceStrategiesProvider` is a `ProjectConfiguration` from the Gen1 CLI import flow. That type
* is not exported, and don't want to pollute the Gen2 / CDK compat interfaces with those legacy attributes, so instead we'll strongly type
* the fields we're interested in operating on.
*/
interface Gen1ProjectConfiguration {
schema: string;
modelToDatasourceMap: Map<
string,
{
dbType: ModelDataSourceStrategyDbType;
provisionDB: boolean;
provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE';
}
>;
customQueries: Map<string, string>;
}
/**
* Utility to fix up the project config generated by the Gen1 CLI flow into the ModelDataSourceStrategy types expected by the transformer
* internals. Internally, this function makes network calls to retrieve the database connection parameter info from SSM, and uses those
* values to discover VPC configurations for the database.
*/
const fixUpDataSourceStrategiesProvider = async (
context: $TSContext,
projectConfig: Gen1ProjectConfiguration,
): Promise<DataSourceStrategiesProvider> => {
const modelToDatasourceMap = projectConfig.modelToDatasourceMap ?? new Map();
const datasourceMapValues: Array<{
dbType: ModelDataSourceStrategyDbType;
provisionDB: boolean;
provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE';
}> = modelToDatasourceMap ? Array.from(modelToDatasourceMap.values()) : [];
// We allow a DynamoDB and SQL data source to live alongside each other, although we don't (yet) support relationships between them. We'll
// process the dbTypes separately so we can validate that there is only one SQL source.
const dataSourceStrategies: Record<string, ModelDataSourceStrategy> = {};
modelToDatasourceMap.forEach((value, key) => {
if (!isDynamoDbType(value.dbType)) {
return;
}
switch (value.provisionStrategy) {
case 'DEFAULT':
dataSourceStrategies[key] = DDB_DEFAULT_DATASOURCE_STRATEGY;
break;
case 'AMPLIFY_TABLE':
dataSourceStrategies[key] = DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY;
break;
default:
throw new Error(`Unsupported provisionStrategy ${value.provisionStrategy}`);
}
});
const sqlDbType = getSqlDbTypeFromDataSourceTypes(datasourceMapValues);
let sqlDirectiveDataSourceStrategies: SqlDirectiveDataSourceStrategy[] | undefined;
if (sqlDbType) {
const dbConnectionConfig = getDbConnectionConfig();
const vpcConfiguration = await isSqlLambdaVpcConfigRequired(context, sqlDbType);
const strategy: SQLLambdaModelDataSourceStrategy = {
name: getDefaultStrategyNameForDbType(sqlDbType),
dbType: sqlDbType,
dbConnectionConfig,
vpcConfiguration,
};
modelToDatasourceMap.forEach((value, key) => {
if (!isSqlDbType(value.dbType)) {
return;
}
dataSourceStrategies[key] = strategy;
});
let customSqlStatements: Record<string, string> | undefined;
if (typeof projectConfig.customQueries === 'object') {
customSqlStatements = {};
(projectConfig.customQueries as Map<string, string>).forEach((value, key) => {
customSqlStatements[key] = value;
});
}
sqlDirectiveDataSourceStrategies = constructSqlDirectiveDataSourceStrategies(projectConfig.schema, strategy, customSqlStatements);
}
return {
dataSourceStrategies,
sqlDirectiveDataSourceStrategies,
};
};
/**
* buildAPIProject
*
* Note that SQL-backed API support is quite limited in this function. Notably:
* - It requires there is only one SQL data source in the API
* - It does not support declaring a SQL schema without a `@model` tied to a SQL data source
*
* TODO: Remove SQL handling from Gen1 CLI.
*/
const buildAPIProject = async (context: $TSContext, opts: TransformerProjectOptions): Promise<DeploymentResources | undefined> => {
// The incoming opts.projectConfig is not actually compatible with DataSourceStrategiesProvider. We will correct that in this function.
const schema = opts.projectConfig.schema.toString();
// Skip building the project if the schema is blank
if (!schema) {
return undefined;
}
const { dataSourceStrategies, sqlDirectiveDataSourceStrategies } = await fixUpDataSourceStrategiesProvider(
context,
opts.projectConfig as unknown as Gen1ProjectConfiguration,
);
checkForUnsupportedDirectives(schema, { dataSourceStrategies });
const useBetaSqlLayer = context?.input?.options?.[USE_BETA_SQL_LAYER] ?? false;
// Read the RDS Mapping S3 Manifest only if the schema contains SQL models or @sql directives.
let rdsLayerMapping: RDSLayerMapping | undefined = undefined;
let rdsSnsTopicMapping: RDSSNSTopicMapping | undefined = undefined;
if (containsSqlModelOrDirective(dataSourceStrategies, sqlDirectiveDataSourceStrategies)) {
rdsLayerMapping = await getRDSLayerMapping(context, useBetaSqlLayer);
rdsSnsTopicMapping = await getRDSSNSTopicMapping(context, useBetaSqlLayer);
}
const transformManager = new TransformManager(
opts.overrideConfig,
hasIamAuth(opts.authConfig),
hasUserPoolAuth(opts.authConfig),
await getAdminRoles(context, opts.resourceName),
await getIdentityPoolId(context),
);
executeTransform({
...opts,
scope: transformManager.rootStack,
nestedStackProvider: transformManager.getNestedStackProvider(),
assetProvider: transformManager.getAssetProvider(),
parameterProvider: transformManager.getParameterProvider(),
synthParameters: transformManager.getSynthParameters(),
schema,
dataSourceStrategies,
sqlDirectiveDataSourceStrategies,
printTransformerLog,
rdsLayerMapping,
rdsSnsTopicMapping,
});
const transformOutput: DeploymentResources = {
...transformManager.generateDeploymentResources(),
userOverriddenSlots: opts.userDefinedSlots ? getUserOverridenSlots(opts.userDefinedSlots) : [],
};
const builtProject = mergeUserConfigWithTransformOutput(opts.projectConfig, transformOutput, opts);
const buildLocation = path.join(opts.projectDirectory, 'build');
const currentCloudLocation = opts.currentCloudBackendDirectory ? path.join(opts.currentCloudBackendDirectory, 'build') : undefined;
if (opts.projectDirectory && !opts.dryRun) {
await writeDeploymentToDisk(context, builtProject, buildLocation, opts.rootStackFileName, opts.buildParameters);
await sanityCheckProject(
currentCloudLocation,
buildLocation,
opts.rootStackFileName,
opts.sanityCheckRules.diffRules,
opts.sanityCheckRules.projectRules,
);
}
return builtProject;
};
export const getUserOverridenSlots = (userDefinedSlots: Record<string, UserDefinedSlot[]>): string[] =>
Object.values(userDefinedSlots)
.flat()
.flatMap((slot) => [slot.requestResolver?.fileName, slot.responseResolver?.fileName])
.flat()
.filter((slotName) => slotName !== undefined);
const getRDSLayerMapping = async (context: $TSContext, useBetaSqlLayer = false): Promise<RDSLayerMapping> => {
const bucket = `${ResourceConstants.RESOURCES.SQLLayerManifestBucket}${useBetaSqlLayer ? '-beta' : ''}`;
const region = context.amplify.getProjectMeta().providers.awscloudformation.Region;
const url = `https://${bucket}.s3.amazonaws.com/${ResourceConstants.RESOURCES.SQLLayerVersionManifestKeyPrefix}${region}`;
const response = await fetch(url);
if (response.status === 200) {
const result = await response.text();
const mapping = {
[region]: {
layerRegion: result,
},
};
return mapping as RDSLayerMapping;
} else {
throw new Error(`Unable to retrieve layer mapping from ${url} with status code ${response.status}.`);
}
};
const getRDSSNSTopicMapping = async (context: $TSContext, useBetaSqlLayer = false): Promise<RDSSNSTopicMapping> => {
const bucket = `${ResourceConstants.RESOURCES.SQLLayerManifestBucket}${useBetaSqlLayer ? '-beta' : ''}`;
const region = context.amplify.getProjectMeta().providers.awscloudformation.Region;
const url = `https://${bucket}.s3.amazonaws.com/${ResourceConstants.RESOURCES.SQLSNSTopicARNManifestKeyPrefix}${region}`;
const response = await fetch(url);
if (response.status === 200) {
const result = await response.text();
const mapping = {
[region]: {
topicArn: result,
},
};
return mapping as RDSSNSTopicMapping;
} else {
throw new Error(`Unable to retrieve sns topic ARN mapping from ${url} with status code ${response.status}.`);
}
};
const isSqlLambdaVpcConfigRequired = async (context: $TSContext, dbType: ModelDataSourceStrategyDbType): Promise<VpcConfig | undefined> => {
// If the database is in VPC, we will use the same VPC configuration for the SQL lambda.
// Customers are required to add inbound rule for port 443 from the private subnet in the Security Group.
// https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-create-vpc.html#vpc-requirements-and-limitations
const vpcSubnetConfig = await getSQLLambdaVpcConfig(context, dbType);
return vpcSubnetConfig;
};
const getDbConnectionConfig = (): SqlModelDataSourceDbConnectionConfig => {
const apiName = getAppSyncAPIName();
const secretsKey = getSecretsKey();
const paths = getExistingConnectionDbConnectionConfig(apiName, secretsKey);
return paths;
};
const getSQLLambdaVpcConfig = async (context: $TSContext, dbType: ModelDataSourceStrategyDbType): Promise<VpcConfig> => {
const [secretsKey, engine] = [getSecretsKey(), getImportedRDSTypeFromStrategyDbType(dbType)];
const { secrets } = await getConnectionSecrets(context, secretsKey, engine);
const region = context.amplify.getProjectMeta().providers.awscloudformation.Region;
const vpcConfig = await getHostVpc(secrets.host, region);
return vpcConfig;
};
const printTransformerLog = (log: TransformerLog): void => {
switch (log.level) {
case TransformerLogLevel.ERROR:
printer.error(log.message);
break;
case TransformerLogLevel.WARN:
printer.warn(log.message);
break;
case TransformerLogLevel.INFO:
printer.info(log.message);
break;
case TransformerLogLevel.DEBUG:
printer.debug(log.message);
break;
default:
printer.error(log.message);
}
};