diff --git a/packages/@aws-cdk/cloudformation-include/test/serverless-transform.test.ts b/packages/@aws-cdk/cloudformation-include/test/serverless-transform.test.ts new file mode 100644 index 0000000000000..3d4d0f977893d --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/serverless-transform.test.ts @@ -0,0 +1,70 @@ +import * as path from 'path'; +import '@aws-cdk/assert/jest'; +import * as core from '@aws-cdk/core'; +import * as inc from '../lib'; +import * as futils from '../lib/file-utils'; + +/* eslint-disable quote-props */ +/* eslint-disable quotes */ + +describe('CDK Include for templates with SAM transform', () => { + let stack: core.Stack; + + beforeEach(() => { + stack = new core.Stack(); + }); + + test('can ingest a template with only a minimal SAM function using S3Location for CodeUri, and output it unchanged', () => { + includeTestTemplate(stack, 'only-minimal-sam-function-codeuri-as-s3location.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-minimal-sam-function-codeuri-as-s3location.yaml'), + ); + }); + + test('can ingest a template with only a SAM function using an array with DDB CRUD for Policies, and output it unchanged', () => { + includeTestTemplate(stack, 'only-sam-function-policies-array-ddb-crud.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-sam-function-policies-array-ddb-crud.yaml'), + ); + }); + + test('can ingest a template with only a minimal SAM function using a parameter for CodeUri, and output it unchanged', () => { + includeTestTemplate(stack, 'only-minimal-sam-function-codeuri-as-param.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-minimal-sam-function-codeuri-as-param.yaml'), + ); + }); + + test('can ingest a template with only a minimal SAM function using a parameter for CodeUri Bucket property, and output it unchanged', () => { + includeTestTemplate(stack, 'only-minimal-sam-function-codeuri-bucket-as-param.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-minimal-sam-function-codeuri-bucket-as-param.yaml'), + ); + }); + + test('can ingest a template with only a SAM function using an array with DDB CRUD for Policies with an Fn::If expression, and output it unchanged', () => { + includeTestTemplate(stack, 'only-sam-function-policies-array-ddb-crud-if.yaml'); + + expect(stack).toMatchTemplate( + loadTestFileToJsObject('only-sam-function-policies-array-ddb-crud-if.yaml'), + ); + }); +}); + +function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { + return new inc.CfnInclude(scope, 'MyScope', { + templateFile: _testTemplateFilePath(testTemplate), + }); +} + +function loadTestFileToJsObject(testTemplate: string): any { + return futils.readYamlSync(_testTemplateFilePath(testTemplate)); +} + +function _testTemplateFilePath(testTemplate: string) { + return path.join(__dirname, 'test-templates', 'sam', testTemplate); +} diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-param.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-param.yaml new file mode 100644 index 0000000000000..5337644447d8f --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-param.yaml @@ -0,0 +1,13 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + CodeLocation: + Type: String +Resources: + MicroserviceHttpEndpoint: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs12.x + CodeUri: + Ref: CodeLocation diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-s3location.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-s3location.yaml new file mode 100644 index 0000000000000..0c6b8a60f367b --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-as-s3location.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Resources: + MicroserviceHttpEndpoint: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs12.x + CodeUri: + Bucket: awsserverlessrepo-changesets-1f9ifp952i9h0 + Key: 123456789012/arn:aws:serverlessrepo:us-east-1:077246666028:applications-microservice-http-endpoint-versions-1.0.4/dc38a8c1-d27f-44f3-b545-4cfff4f8b865 diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-bucket-as-param.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-bucket-as-param.yaml new file mode 100644 index 0000000000000..edcec53936097 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-minimal-sam-function-codeuri-bucket-as-param.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + CodeLocation: + Type: String +Resources: + MicroserviceHttpEndpoint: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs12.x + CodeUri: + Bucket: + Ref: CodeLocation + Key: my-key diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud-if.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud-if.yaml new file mode 100644 index 0000000000000..f8d6a15bf5cdb --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud-if.yaml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Conditions: + SomeCondition: + Fn::Equals: [1, 2] +Parameters: + TableNameParameter: + Type: String +Resources: + MicroserviceHttpEndpoint: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs12.x + CodeUri: + Bucket: awsserverlessrepo-changesets-1f9ifp952i9h0 + Key: 828671620168/arn:aws:serverlessrepo:us-east-1:077246666028:applications-microservice-http-endpoint-versions-1.0.4/dc38a8c1-d27f-44f3-b545-4cfff4f8b865 + Policies: + - Fn::If: + - SomeCondition + - DynamoDBCrudPolicy: + TableName: + Ref: TableNameParameter + - DynamoDBCrudPolicy: + TableName: + Ref: TableNameParameter diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud.yaml new file mode 100644 index 0000000000000..6b68a0a2fdec3 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/sam/only-sam-function-policies-array-ddb-crud.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + TableNameParameter: + Type: String +Resources: + MicroserviceHttpEndpoint: + Type: AWS::Serverless::Function + Properties: + Handler: index.handler + Runtime: nodejs12.x + CodeUri: my-code-uri + Policies: + - DynamoDBCrudPolicy: + TableName: + Ref: TableNameParameter diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 87bc1e3c193e5..5872399e0efee 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -10,6 +10,7 @@ import { ICfnFinder } from './from-cfn'; import { Lazy } from './lazy'; import { CfnReference } from './private/cfn-reference'; import { IResolvable } from './resolvable'; +import { Mapper, Validator } from './runtime'; import { isResolvableObject, Token } from './token'; /** @@ -78,36 +79,40 @@ export class FromCloudFormation { } // in all other cases, delegate to the standard mapping logic - return this.getArray(value, this.getString); + return this.getArray(this.getString)(value); } - public static getArray(value: any, mapper: (arg: any) => T): T[] { - if (!Array.isArray(value)) { - // break the type system, and just return the given value, - // which hopefully will be reported as invalid by the validator - // of the property we're transforming - // (unless it's a deploy-time value, - // which we can't map over at build time anyway) - return value; - } + public static getArray(mapper: (arg: any) => T): (x: any) => T[] { + return (value: any) => { + if (!Array.isArray(value)) { + // break the type system, and just return the given value, + // which hopefully will be reported as invalid by the validator + // of the property we're transforming + // (unless it's a deploy-time value, + // which we can't map over at build time anyway) + return value; + } - return value.map(mapper); + return value.map(mapper); + }; } - public static getMap(value: any, mapper: (arg: any) => T): { [key: string]: T } { - if (typeof value !== 'object') { - // if the input is not a map (= object in JS land), - // just return it, and let the validator of this property handle it - // (unless it's a deploy-time value, - // which we can't map over at build time anyway) - return value; - } + public static getMap(mapper: (arg: any) => T): (x: any) => { [key: string]: T } { + return (value: any) => { + if (typeof value !== 'object') { + // if the input is not a map (= object in JS land), + // just return it, and let the validator of this property handle it + // (unless it's a deploy-time value, + // which we can't map over at build time anyway) + return value; + } - const ret: { [key: string]: T } = {}; - for (const [key, val] of Object.entries(value)) { - ret[key] = mapper(val); - } - return ret; + const ret: { [key: string]: T } = {}; + for (const [key, val] of Object.entries(value)) { + ret[key] = mapper(val); + } + return ret; + }; } public static getCfnTag(tag: any): CfnTag { @@ -118,6 +123,23 @@ export class FromCloudFormation { value: tag.Value, }; } + + /** + * Return a function that, when applied to a value, will return the first validly deserialized one + */ + public static getTypeUnion(validators: Validator[], mappers: Mapper[]): (x: any) => any { + return (value: any): any => { + for (let i = 0; i < validators.length; i++) { + const candidate = mappers[i](value); + if (validators[i](candidate).isSuccess) { + return candidate; + } + } + + // if nothing matches, just return the input unchanged, and let validators catch it + return value; + }; + } } /** diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index 9421860606f26..953587a40dec3 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -964,13 +964,14 @@ function mergeSection(section: string, val1: any, val2: any): any { throw new Error(`Conflicting CloudFormation template versions provided: '${val1}' and '${val2}`); } return val1 ?? val2; + case 'Transform': + return mergeSets(val1, val2); case 'Resources': case 'Conditions': case 'Parameters': case 'Outputs': case 'Mappings': case 'Metadata': - case 'Transform': return mergeObjectsWithoutDuplicates(section, val1, val2); default: throw new Error(`CDK doesn't know how to merge two instances of the CFN template section '${section}' - ` + @@ -978,6 +979,17 @@ function mergeSection(section: string, val1: any, val2: any): any { } } +function mergeSets(val1: any, val2: any): any { + const array1 = val1 == null ? [] : (Array.isArray(val1) ? val1 : [val1]); + const array2 = val2 == null ? [] : (Array.isArray(val2) ? val2 : [val2]); + for (const value of array2) { + if (!array1.includes(value)) { + array1.push(value); + } + } + return array1.length === 1 ? array1[0] : array1; +} + function mergeObjectsWithoutDuplicates(section: string, dest: any, src: any): any { if (typeof dest !== 'object') { throw new Error(`Expecting ${JSON.stringify(dest)} to be an object`); diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 9d555dc0fdde0..6624053395dc2 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -545,72 +545,70 @@ export default class CodeGenerator { // class used for the visitor class FromCloudFormationFactoryVisitor implements genspec.PropertyVisitor { - constructor( - private readonly baseExpression: string, - private readonly optionalProperty: boolean, - private readonly cfnPropName: string, - private readonly depth: number = 1) { - } - public visitAtom(type: genspec.CodeName): string { const specType = type.specName && self.spec.PropertyTypes[type.specName.fqn]; if (specType && !schema.isRecordType(specType)) { return genspec.typeDispatch(resource, specType, this); } else { - const optionalPreamble = this.optionalProperty - ? `${this.baseExpression} == null ? undefined : ` - : ''; - const suffix = schema.isTagPropertyName(this.cfnPropName) - // Properties that have names considered to denote tags - // have their type generated without a union with IResolvable. - // However, we can't possibly know that when generating the factory - // for that struct, and (in theory, at least) - // the same type can be used as the value of multiple properties, - // some of which do not have a tag-compatible name, - // so there is no way to pass allowReturningIResolvable=false correctly. - // Do the simple thing in that case, and just cast to any. - ? ' as any' - : ''; - return `${optionalPreamble}${genspec.fromCfnFactoryName(type).fqn}(${this.baseExpression})${suffix}`; + return genspec.fromCfnFactoryName(type).fqn; } } public visitList(itemType: genspec.CodeName): string { - const arg = `prop${this.depth}`; return itemType.className === 'string' // an array of strings is a special case, // because it might need to be encoded as a Token directly // (and not an array of tokens), for example, // when a Ref expression references a parameter of type CommaDelimitedList - ? `${CFN_PARSE}.FromCloudFormation.getStringArray(${this.baseExpression})` - : `${CFN_PARSE}.FromCloudFormation.getArray(${this.baseExpression}, (${arg}: any) => ` + - `${this.deeperCopy(arg).visitAtom(itemType)})`; + ? `${CFN_PARSE}.FromCloudFormation.getStringArray` + : `${CFN_PARSE}.FromCloudFormation.getArray(${this.visitAtom(itemType)})`; } public visitMap(itemType: genspec.CodeName): string { - const arg = `prop${this.depth}`; - return `${CFN_PARSE}.FromCloudFormation.getMap(${this.baseExpression}, (${arg}: any) => ` + - `${this.deeperCopy(arg).visitAtom(itemType)})`; + return `${CFN_PARSE}.FromCloudFormation.getMap(${this.visitAtom(itemType)})`; } - public visitAtomUnion(_types: genspec.CodeName[]): string { - return this.baseExpression; - } + public visitAtomUnion(types: genspec.CodeName[]): string { + const validatorNames = types.map(type => genspec.validatorName(type).fqn).join(', '); + const mappers = types.map(type => this.visitAtom(type)).join(', '); - public visitListOrAtom(_scalarTypes: genspec.CodeName[], _itemTypes: genspec.CodeName[]): any { - return this.baseExpression; + return `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${validatorNames}], [${mappers}])`; } - public visitUnionList(_itemTypes: genspec.CodeName[]): string { - return this.baseExpression; + public visitUnionList(itemTypes: genspec.CodeName[]): string { + const validatorNames = itemTypes.map(type => genspec.validatorName(type).fqn).join(', '); + const mappers = itemTypes.map(type => this.visitAtom(type)).join(', '); + + return `${CFN_PARSE}.FromCloudFormation.getArray(` + + `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${validatorNames}], [${mappers}])` + + ')'; } - public visitUnionMap(_itemTypes: genspec.CodeName[]): string { - return this.baseExpression; + public visitUnionMap(itemTypes: genspec.CodeName[]): string { + const validatorNames = itemTypes.map(type => genspec.validatorName(type).fqn).join(', '); + const mappers = itemTypes.map(type => this.visitAtom(type)).join(', '); + + return `${CFN_PARSE}.FromCloudFormation.getMap(` + + `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${validatorNames}], [${mappers}])` + + ')'; } - private deeperCopy(baseExpression: string): FromCloudFormationFactoryVisitor { - return new FromCloudFormationFactoryVisitor(baseExpression, false, this.cfnPropName, this.depth + 1); + public visitListOrAtom(scalarTypes: genspec.CodeName[], itemTypes: genspec.CodeName[]): any { + const scalarValidatorNames = scalarTypes.map(type => genspec.validatorName(type).fqn).join(', '); + const itemValidatorNames = itemTypes.map(type => genspec.validatorName(type).fqn).join(', '); + + const scalarTypesMappers = scalarTypes.map(type => this.visitAtom(type)).join(', '); + const scalarMapper = `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${scalarValidatorNames}], [${scalarTypesMappers}])`; + + const itemTypeMappers = itemTypes.map(type => this.visitAtom(type)).join(', '); + const listMapper = `${CFN_PARSE}.FromCloudFormation.getArray(` + + `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${itemValidatorNames}], [${itemTypeMappers}])` + + ')'; + + const scalarValidator = `${CORE}.unionValidator(${scalarValidatorNames})`; + const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${itemValidatorNames}))`; + + return `${CFN_PARSE}.FromCloudFormation.getTypeUnion([${scalarValidator}, ${listValidator}], [${scalarMapper}, ${listMapper}])`; } } @@ -619,10 +617,24 @@ export default class CodeGenerator { const propSpec = propSpecs[cfnName]; const simpleCfnPropAccessExpr = `properties.${cfnName}`; - const mapperExpression = genspec.typeDispatch(resource, propSpec, - new FromCloudFormationFactoryVisitor(simpleCfnPropAccessExpr, !propSpec.Required, cfnName)); - self.code.line(`${propName}: ${mapperExpression},`); + const deserializer = genspec.typeDispatch(resource, propSpec, new FromCloudFormationFactoryVisitor()); + const deserialized = `${deserializer}(${simpleCfnPropAccessExpr})`; + let valueExpression = propSpec.Required ? deserialized : `${simpleCfnPropAccessExpr} != null ? ${deserialized} : undefined`; + + if (schema.isTagPropertyName(cfnName)) { + // Properties that have names considered to denote tags + // have their type generated without a union with IResolvable. + // However, we can't possibly know that when generating the factory + // for that struct, and (in theory, at least) + // the same type can be used as the value of multiple properties, + // some of which do not have a tag-compatible name, + // so there is no way to pass allowReturningIResolvable=false correctly. + // Do the simple thing in that case, and just cast to any. + valueExpression += ' as any'; + } + + self.code.line(`${propName}: ${valueExpression},`); }); // close the return object brace this.code.unindent('};'); @@ -935,4 +947,4 @@ interface EmitPropertyProps { propName: string; spec: schema.Property; additionalDocs: string; -} +} \ No newline at end of file