packages/@aws-cdk/toolkit-lib/lib/api/hotswap/appsync-mapping-templates.ts (175 lines of code) (raw):
import type {
GetSchemaCreationStatusCommandOutput,
GetSchemaCreationStatusCommandInput,
} from '@aws-sdk/client-appsync';
import {
type HotswapChange,
classifyChanges,
} from './common';
import type { ResourceChange } from '../../payloads/hotswap';
import { lowerCaseFirstCharacter, transformObjectKeys } from '../../util';
import type { SDK } from '../aws-auth/private';
import type { EvaluateCloudFormationTemplate } from '../cloudformation';
import { ToolkitError } from '../toolkit-error';
export async function isHotswappableAppSyncChange(
logicalId: string,
change: ResourceChange,
evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<HotswapChange[]> {
const isResolver = change.newValue.Type === 'AWS::AppSync::Resolver';
const isFunction = change.newValue.Type === 'AWS::AppSync::FunctionConfiguration';
const isGraphQLSchema = change.newValue.Type === 'AWS::AppSync::GraphQLSchema';
const isAPIKey = change.newValue.Type === 'AWS::AppSync::ApiKey';
if (!isResolver && !isFunction && !isGraphQLSchema && !isAPIKey) {
return [];
}
const ret: HotswapChange[] = [];
const classifiedChanges = classifyChanges(change, [
'RequestMappingTemplate',
'RequestMappingTemplateS3Location',
'ResponseMappingTemplate',
'ResponseMappingTemplateS3Location',
'Code',
'CodeS3Location',
'Definition',
'DefinitionS3Location',
'Expires',
]);
classifiedChanges.reportNonHotswappablePropertyChanges(ret);
const namesOfHotswappableChanges = Object.keys(classifiedChanges.hotswappableProps);
if (namesOfHotswappableChanges.length > 0) {
let physicalName: string | undefined = undefined;
const arn = await evaluateCfnTemplate.establishResourcePhysicalName(
logicalId,
isFunction ? change.newValue.Properties?.Name : undefined,
);
if (isResolver) {
const arnParts = arn?.split('/');
physicalName = arnParts ? `${arnParts[3]}.${arnParts[5]}` : undefined;
} else {
physicalName = arn;
}
// nothing do here
if (!physicalName) {
return ret;
}
ret.push({
change: {
cause: change,
resources: [{
logicalId,
resourceType: change.newValue.Type,
physicalName,
metadata: evaluateCfnTemplate.metadataFor(logicalId),
}],
},
hotswappable: true,
service: 'appsync',
apply: async (sdk: SDK) => {
const sdkProperties: { [name: string]: any } = {
...change.oldValue.Properties,
Definition: change.newValue.Properties?.Definition,
DefinitionS3Location: change.newValue.Properties?.DefinitionS3Location,
requestMappingTemplate: change.newValue.Properties?.RequestMappingTemplate,
requestMappingTemplateS3Location: change.newValue.Properties?.RequestMappingTemplateS3Location,
responseMappingTemplate: change.newValue.Properties?.ResponseMappingTemplate,
responseMappingTemplateS3Location: change.newValue.Properties?.ResponseMappingTemplateS3Location,
code: change.newValue.Properties?.Code,
codeS3Location: change.newValue.Properties?.CodeS3Location,
expires: change.newValue.Properties?.Expires,
};
const evaluatedResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression(sdkProperties);
const sdkRequestObject = transformObjectKeys(evaluatedResourceProperties, lowerCaseFirstCharacter);
// resolve s3 location files as SDK doesn't take in s3 location but inline code
if (sdkRequestObject.requestMappingTemplateS3Location) {
sdkRequestObject.requestMappingTemplate = await fetchFileFromS3(
sdkRequestObject.requestMappingTemplateS3Location,
sdk,
);
delete sdkRequestObject.requestMappingTemplateS3Location;
}
if (sdkRequestObject.responseMappingTemplateS3Location) {
sdkRequestObject.responseMappingTemplate = await fetchFileFromS3(
sdkRequestObject.responseMappingTemplateS3Location,
sdk,
);
delete sdkRequestObject.responseMappingTemplateS3Location;
}
if (sdkRequestObject.definitionS3Location) {
sdkRequestObject.definition = await fetchFileFromS3(sdkRequestObject.definitionS3Location, sdk);
delete sdkRequestObject.definitionS3Location;
}
if (sdkRequestObject.codeS3Location) {
sdkRequestObject.code = await fetchFileFromS3(sdkRequestObject.codeS3Location, sdk);
delete sdkRequestObject.codeS3Location;
}
if (isResolver) {
await sdk.appsync().updateResolver(sdkRequestObject);
} else if (isFunction) {
// Function version is only applicable when using VTL and mapping templates
// Runtime only applicable when using code (JS mapping templates)
if (sdkRequestObject.code) {
delete sdkRequestObject.functionVersion;
} else {
delete sdkRequestObject.runtime;
}
const functions = await sdk.appsync().listFunctions({ apiId: sdkRequestObject.apiId });
const { functionId } = functions.find((fn) => fn.name === physicalName) ?? {};
// Updating multiple functions at the same time or along with graphql schema results in `ConcurrentModificationException`
await exponentialBackOffRetry(
() =>
sdk.appsync().updateFunction({
...sdkRequestObject,
functionId: functionId,
}),
6,
1000,
'ConcurrentModificationException',
);
} else if (isGraphQLSchema) {
let schemaCreationResponse: GetSchemaCreationStatusCommandOutput = await sdk
.appsync()
.startSchemaCreation(sdkRequestObject);
while (
schemaCreationResponse.status &&
['PROCESSING', 'DELETING'].some((status) => status === schemaCreationResponse.status)
) {
await sleep(1000); // poll every second
const getSchemaCreationStatusRequest: GetSchemaCreationStatusCommandInput = {
apiId: sdkRequestObject.apiId,
};
schemaCreationResponse = await sdk.appsync().getSchemaCreationStatus(getSchemaCreationStatusRequest);
}
if (schemaCreationResponse.status === 'FAILED') {
throw new ToolkitError(schemaCreationResponse.details ?? 'Schema creation has failed.');
}
} else {
// isApiKey
if (!sdkRequestObject.id) {
// ApiKeyId is optional in CFN but required in SDK. Grab the KeyId from physicalArn if not available as part of CFN template
const arnParts = physicalName?.split('/');
if (arnParts && arnParts.length === 4) {
sdkRequestObject.id = arnParts[3];
}
}
await sdk.appsync().updateApiKey(sdkRequestObject);
}
},
});
}
return ret;
}
async function fetchFileFromS3(s3Url: string, sdk: SDK) {
const s3PathParts = s3Url.split('/');
const s3Bucket = s3PathParts[2]; // first two are "s3:" and "" due to s3://
const s3Key = s3PathParts.splice(3).join('/'); // after removing first three we reconstruct the key
return (await sdk.s3().getObject({ Bucket: s3Bucket, Key: s3Key })).Body?.transformToString();
}
async function exponentialBackOffRetry(fn: () => Promise<any>, numOfRetries: number, backOff: number, errorCodeToRetry: string) {
try {
await fn();
} catch (error: any) {
if (error && error.name === errorCodeToRetry && numOfRetries > 0) {
await sleep(backOff); // time to wait doubles everytime function fails, starts at 1 second
await exponentialBackOffRetry(fn, numOfRetries - 1, backOff * 2, errorCodeToRetry);
} else {
throw error;
}
}
}
async function sleep(ms: number) {
return new Promise((ok) => setTimeout(ok, ms));
}