@aws-amplify/amplify-category-api
Version:
Amplify CLI API Category Plugin
395 lines (358 loc) • 16.8 kB
text/typescript
import path from 'path';
import {
$TSContext,
$TSMeta,
AmplifyCategories,
AmplifySupportedService,
CloudformationProviderFacade,
getGraphQLTransformerAuthDocLink,
JSONUtilities,
pathManager,
stateManager,
} from '@aws-amplify/amplify-cli-core';
import { AppSyncAuthConfiguration, TransformerPluginProvider } from '@aws-amplify/graphql-transformer-interfaces';
import { collectDirectivesByTypeNames } from '@aws-amplify/graphql-transformer-core';
import { getSanityCheckRules, loadProject } from 'graphql-transformer-core';
import fs from 'fs-extra';
import { ResourceConstants } from 'graphql-transformer-common';
import _ from 'lodash';
import { printer } from '@aws-amplify/amplify-prompts';
import type { TransformParameters } from '@aws-amplify/graphql-transformer-interfaces';
import { Construct } from 'constructs';
import { contextUtil } from '../category-utils/context-util';
import { shouldEnableNodeToNodeEncryption } from '../provider-utils/awscloudformation/current-backend-state/searchable-node-to-node-encryption';
import { schemaHasSandboxModeEnabled, showGlobalSandboxModeWarning, showSandboxModePrompts } from './sandbox-mode-helpers';
import { importTransformerModule } from './transformer-factory';
import { AmplifyCLIFeatureFlagAdapter } from './amplify-cli-feature-flag-adapter';
import { DESTRUCTIVE_UPDATES_FLAG, PARAMETERS_FILENAME, PROVIDER_NAME, ROOT_APPSYNC_S3_KEY } from './constants';
import { TransformerProjectOptions } from './transformer-options-types';
import { searchablePushChecks } from './api-utils';
import { parseUserDefinedSlots } from './user-defined-slots';
import { applyFileBasedOverride } from './override';
import { OverrideConfig } from './cdk-compat/transform-manager';
export const APPSYNC_RESOURCE_SERVICE = 'AppSync';
const warnOnAuth = (map: Record<string, any>, docLink: string): void => {
const unAuthModelTypes = Object.keys(map).filter((type) => !map[type].includes('auth') && map[type].includes('model'));
if (unAuthModelTypes.length) {
printer.info(
`
⚠️ WARNING: Some types do not have authorization rules configured. That means all create, read, update, and delete operations are denied on these types:`,
'yellow',
);
printer.info(unAuthModelTypes.map((type) => `\t - ${type}`).join('\n'), 'yellow');
printer.info(`Learn more about "@auth" authorization rules here: ${docLink}`, 'yellow');
}
};
/**
* Use current context to generate the Transformer options for generating
* a GraphQL Transformer V2 object
* @param context The $TSContext from the Amplify CLI
* @param options The any options config coming from the Amplify CLI
*/
export const generateTransformerOptions = async (context: $TSContext, options: any): Promise<TransformerProjectOptions> => {
let resourceName: string;
const backEndDir = pathManager.getBackendDirPath();
const flags = context.parameters.options;
if (flags['no-gql-override']) {
return undefined;
}
let { 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.existsSync(buildDir);
});
resources = resources.concat(resourceNeedCompile);
if (forceCompile) {
resources = resources.concat(allResources);
}
resources = resources.filter((resource) => resource.service === APPSYNC_RESOURCE_SERVICE);
const resourceDir = await contextUtil.getResourceDir(context, options);
let previouslyDeployedBackendDir = options.cloudBackendDirectory;
if (!previouslyDeployedBackendDir) {
if (resources.length > 0) {
const resource = resources[0];
if (resource.providerPlugin !== PROVIDER_NAME) {
return undefined;
}
const { category } = resource;
resourceName = resource.resourceName;
const cloudBackendRootDir = pathManager.getCurrentCloudBackendDirPath();
/* eslint-disable */
previouslyDeployedBackendDir = path.normalize(path.join(cloudBackendRootDir, category, resourceName));
/* eslint-enable */
}
}
const parametersFilePath = path.join(resourceDir, PARAMETERS_FILENAME);
if (!parameters && fs.existsSync(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);
}
}
}
// for the predictions directive get storage config
const s3Resource = s3ResourceAlreadyExists();
const storageConfig = s3Resource ? getBucketName(s3Resource) : undefined;
let deploymentRootKey = await getPreviousDeploymentRootKey(previouslyDeployedBackendDir);
if (!deploymentRootKey) {
const deploymentSubKey = await CloudformationProviderFacade.hashDirectory(context, resourceDir);
deploymentRootKey = `${ROOT_APPSYNC_S3_KEY}/${deploymentSubKey}`;
}
const projectBucket = options.dryRun ? 'fake-bucket' : getProjectBucket();
const buildParameters = {
...parameters,
S3DeploymentBucket: projectBucket,
S3DeploymentRootKey: deploymentRootKey,
};
// The project configuration loaded here uses the Gen1 CLI DataSourceTypes and modelToDatasourceMap to hold model information. We'll
// convert it to the supported ModelDataSourceStrategy types later.
const project = await loadProject(resourceDir);
const lastDeployedProjectConfig = fs.existsSync(previouslyDeployedBackendDir)
? await loadProject(previouslyDeployedBackendDir)
: undefined;
const docLink = getGraphQLTransformerAuthDocLink(2);
const sandboxModeEnabled = schemaHasSandboxModeEnabled(project.schema, docLink);
const directiveMap = collectDirectivesByTypeNames(project.schema);
const hasApiKey =
authConfig.defaultAuthentication.authenticationType === 'API_KEY' ||
authConfig.additionalAuthenticationProviders.some((authProvider) => authProvider.authenticationType === 'API_KEY');
const showSandboxModeMessage = sandboxModeEnabled && hasApiKey;
await searchablePushChecks(context, directiveMap.types, parameters[ResourceConstants.PARAMETERS.AppSyncApiName]);
if (sandboxModeEnabled && options.promptApiKeyCreation) {
const apiKeyConfig = await showSandboxModePrompts(context);
if (apiKeyConfig) authConfig.additionalAuthenticationProviders.push(apiKeyConfig);
}
if (showSandboxModeMessage) {
showGlobalSandboxModeWarning(docLink);
} else {
warnOnAuth(directiveMap.types, docLink);
}
// construct sanityCheckRules
const ff = new AmplifyCLIFeatureFlagAdapter();
const isNewAppSyncAPI: boolean = resourcesToBeCreated.some((resource) => resource.service === 'AppSync');
const allowDestructiveUpdates = context?.input?.options?.[DESTRUCTIVE_UPDATES_FLAG] || context?.input?.options?.force;
const sanityCheckRules = getSanityCheckRules(isNewAppSyncAPI, ff, allowDestructiveUpdates);
let resolverConfig = {};
if (!_.isEmpty(resources)) {
resolverConfig = await context.amplify.invokePluginMethod(
context,
AmplifyCategories.API,
AmplifySupportedService.APPSYNC,
'getResolverConfig',
[context, resources[0].resourceName],
);
}
/**
* if Auth is not migrated , we need to fetch resolver Config from transformer.conf.json
* since above function will return empty object
*/
if (_.isEmpty(resolverConfig)) {
resolverConfig = project.config.ResolverConfig;
}
const resourceDirParts = resourceDir.split(path.sep);
const apiName = resourceDirParts[resourceDirParts.length - 1];
const userDefinedSlots = {
...parseUserDefinedSlots(project.pipelineFunctions),
...parseUserDefinedSlots(project.resolvers),
};
const overrideConfig: OverrideConfig = {
applyOverride: (scope: Construct) => applyFileBasedOverride(scope),
...options.overrideConfig,
};
return {
...options,
resourceName,
buildParameters,
projectDirectory: resourceDir,
transformersFactoryArgs: {
storageConfig,
customTransformers: await loadCustomTransformersV2(resourceDir),
},
rootStackFileName: 'cloudformation-template.json',
currentCloudBackendDirectory: previouslyDeployedBackendDir,
// Reminder that `project` has type `any`, and is not actually compatible with DataSourceStrategiesProvider. We will correct that later.
projectConfig: project,
lastDeployedProjectConfig,
authConfig,
sandboxModeEnabled,
sanityCheckRules,
resolverConfig,
userDefinedSlots,
overrideConfig,
stacks: project.stacks,
stackMapping: project.config.StackMapping,
transformParameters: generateTransformParameters(apiName, parameters, project.config, sandboxModeEnabled),
};
};
/**
* Generate transform parameters from feature flags and other config sources.
* @param apiName the api name (used for determining current nodeToNodeEncryption value)
* @param parameters invocation params, bag of bits, used for determining suppressApiKeyGeneration state
* @param projectConfig hydrated project config object, with additional metadata, bag of bits, determines if resolver deduping is disabled
* @param sandboxModeEnabled whether or not to enable sandbox mode on the transformed project
* @returns a single set of params to configure the transform behavior.
*/
const generateTransformParameters = (
apiName: string,
parameters: any,
projectConfig: any,
sandboxModeEnabled: boolean,
): TransformParameters => {
const featureFlagProvider = new AmplifyCLIFeatureFlagAdapter();
return {
shouldDeepMergeDirectiveConfigDefaults: featureFlagProvider.getBoolean('shouldDeepMergeDirectiveConfigDefaults'),
useSubUsernameForDefaultIdentityClaim: featureFlagProvider.getBoolean('useSubUsernameForDefaultIdentityClaim'),
populateOwnerFieldForStaticGroupAuth: featureFlagProvider.getBoolean('populateOwnerFieldForStaticGroupAuth'),
secondaryKeyAsGSI: featureFlagProvider.getBoolean('secondaryKeyAsGSI'),
enableAutoIndexQueryNames: featureFlagProvider.getBoolean('enableAutoIndexQueryNames'),
respectPrimaryKeyAttributesOnConnectionField: featureFlagProvider.getBoolean('respectPrimaryKeyAttributesOnConnectionField'),
subscriptionsInheritPrimaryAuth: featureFlagProvider.getBoolean('subscriptionsInheritPrimaryAuth'),
suppressApiKeyGeneration: suppressApiKeyGeneration(parameters),
disableResolverDeduping: projectConfig.DisableResolverDeduping ?? false,
enableSearchNodeToNodeEncryption: shouldEnableNodeToNodeEncryption(
apiName,
pathManager.findProjectRoot(),
pathManager.getCurrentCloudBackendDirPath(),
),
sandboxModeEnabled,
enableTransformerCfnOutputs: true,
allowDestructiveGraphqlSchemaUpdates: false,
replaceTableUponGsiUpdate: false,
allowGen1Patterns: true,
enableGen2Migration: featureFlagProvider.getBoolean('enableGen2Migration'),
};
};
export const suppressApiKeyGeneration = (parameters: any): boolean => {
if (!('CreateAPIKey' in parameters)) {
return false;
}
return parameters.CreateAPIKey !== 1 && parameters.CreateAPIKey !== '1';
};
/**
* Scan the project config for custom transformers, then attempt to load them from the various node paths which Amplify supports.
* @param resourceDir the directory to search for transformer configuration.
* @returns a list of custom plugins.
*/
export const loadCustomTransformersV2 = async (resourceDir: string): Promise<TransformerPluginProvider[]> => {
const customTransformersConfig = await loadProject(resourceDir);
const customTransformerList = customTransformersConfig?.config?.transformers;
return (Array.isArray(customTransformerList) ? customTransformerList : [])
.map(importTransformerModule)
.map((imported) => {
const CustomTransformer = imported.default;
if (typeof CustomTransformer === 'function') {
return new CustomTransformer();
}
if (typeof CustomTransformer === 'object') {
// Todo: Use a shim to ensure that it adheres to TransformerProvider interface. For now throw error
// return CustomTransformer;
throw new Error("Custom Transformers' should implement TransformerProvider interface");
}
throw new Error("Custom Transformers' default export must be a function or an object");
})
.filter((customTransformer) => customTransformer);
};
const getBucketName = (s3ResourceName: string): { bucketName: string } => {
const amplifyMeta = stateManager.getMeta();
const stackName = amplifyMeta.providers.awscloudformation.StackName;
const s3ResourcePath = pathManager.getResourceDirectoryPath(undefined, AmplifyCategories.STORAGE, s3ResourceName);
const cliInputsPath = path.join(s3ResourcePath, 'cli-inputs.json');
let bucketParameters: Record<string, any>;
// get bucketParameters 1st from cli-inputs , if not present, then parameters.json
if (fs.existsSync(cliInputsPath)) {
bucketParameters = JSONUtilities.readJson(cliInputsPath);
} else {
bucketParameters = stateManager.getResourceParametersJson(undefined, AmplifyCategories.STORAGE, s3ResourceName);
}
const bucketName = stackName.startsWith('amplify-')
? `${bucketParameters.bucketName}\${hash}-\${env}`
: `${bucketParameters.bucketName}${s3ResourceName}-\${env}`;
return { bucketName };
};
const getPreviousDeploymentRootKey = async (previouslyDeployedBackendDir: string): Promise<string | undefined> => {
// this is the function
let parameters;
try {
const parametersPath = path.join(previouslyDeployedBackendDir, `build/${PARAMETERS_FILENAME}`);
const parametersExists = fs.existsSync(parametersPath);
if (parametersExists) {
const parametersString = await fs.readFile(parametersPath);
parameters = JSON.parse(parametersString.toString());
}
return parameters.S3DeploymentRootKey;
} catch (err) {
return undefined;
}
};
const getProjectBucket = (): string => {
const meta: $TSMeta = stateManager.getMeta(undefined, { throwIfNotExist: false });
const projectBucket = meta?.providers ? meta.providers[PROVIDER_NAME].DeploymentBucketName : '';
return projectBucket;
};
/**
* Check if storage exists in the project if not return undefined
*/
const s3ResourceAlreadyExists = (): string | undefined => {
try {
let resourceName: string;
const amplifyMeta: $TSMeta = stateManager.getMeta(undefined, { throwIfNotExist: false });
if (amplifyMeta?.[AmplifyCategories.STORAGE]) {
const categoryResources = amplifyMeta[AmplifyCategories.STORAGE];
Object.keys(categoryResources).forEach((resource) => {
if (categoryResources[resource].service === AmplifySupportedService.S3) {
resourceName = resource;
}
});
}
return resourceName;
} catch (error) {
if (error.name === 'UndeterminedEnvironmentError') {
return undefined;
}
throw error;
}
};