From 521570a7c3a3788ce313f310ccb35bd1484ad2f5 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Wed, 26 Sep 2018 23:08:03 +0300 Subject: [PATCH 1/6] Resource overrides (escape hatch) Adds capabilities and documentation to allow users to add overrides to synthesized resource definitions in case of gaps in L2s. - resource.addOverride(path, value) - resource.addPropertyOverride(propertyPath, value) - resource.addDeletionOverride(p, v) - resource.addPropertyDeletionOVerride(pp, v) - xxxResource.propertyOverrides (of type XxxResourceProps) Fixes #606 BREAKING CHANGE: The logical ID of the AWS::AutoScaling::AutoScalingGroup resource synthesized by @aws-cdk/aws-autoscaling.AutoScalingGroup has been changed. --- docs/src/aws-construct-lib.rst | 275 +++++++++++++++++- .../resource-overrides/cdk.json | 22 ++ .../resource-overrides/index.ts | 118 ++++++++ .../cdk/lib/cloudformation/resource.ts | 132 ++++++++- .../cdk/test/cloudformation/test.resource.ts | 175 +++++++++++ tools/cfn2ts/lib/codegen.ts | 8 +- 6 files changed, 716 insertions(+), 14 deletions(-) create mode 100644 examples/cdk-examples-typescript/resource-overrides/cdk.json create mode 100644 examples/cdk-examples-typescript/resource-overrides/index.ts diff --git a/docs/src/aws-construct-lib.rst b/docs/src/aws-construct-lib.rst index 118b17393a804..dbcaaa2b865f1 100644 --- a/docs/src/aws-construct-lib.rst +++ b/docs/src/aws-construct-lib.rst @@ -103,9 +103,274 @@ were part of your app. .. _cloudformation_layer: -AWS CloudFormation Layer -======================== +Access the AWS CloudFormation Layer +=================================== + +This topic discusses ways to work around gaps and missing features at the AWS +Construct Library. We also refer to this as an "escape hatch", as it allows +users to escape from the abstraction boundary defined by the AWS Construct and +patch the underlying resources. + +.. important:: + + **We generally do not recommend this method, as it breaks the abstraction + layer and can potentially produce invalid results**. + + Furthermore, the internal implementation of an AWS construct is not part of + the API compatibility guarantees that we can make. This means that updates to + the construct library may break your code without a major version bump. + +AWS constructs, such as :py:class:`Topic <@aws-cdk/aws-sns.Topic>`, encapsulate +one or more AWS CloudFormation resources behind their APIs. These resources are +also represented as constructs under the ``cloudformation`` namespace in each +library. For example, the :py:class:`@aws-cdk/aws-s3.Bucket` construct +encapsulates the :py:class:`@aws-cdk/aws-s3.cloudformation.BucketResource`. When +a stack that includes an AWS construct is synthesized, the CloudFormation +definition of the underlying resources are included in the resulting template. + +In the fullness of time, the APIs provided by AWS constructs are expected to +support all the services and capabilities offered by AWS, but we are aware that +the library still has many gaps both at the service level (some services simply +don't have any constructs yet) and at the resource level (an AWS construct +exists, but some features are missing). + +.. note:: + + If you encounter a missing capability in the AWS Construct Library, whether + it is an entire library, a specific resource or a feature, please consider to + `raise an issue `_ on GitHub, + and letting us know. + +This topic covers the following use cases: + +- How to access CloudFormation the internal resources encapsulated by an AWS construct +- How to specify resource options such as metadata, dependencies on resources +- How to add overrides to a CloudFormation resource and property definitions +- How to directly define low-level CloudFormation resources without an AWS construct + +You can also find more information on how to work directly with the AWS +CloudFormation layer under :py:doc:`cloudformation`. + +Accessing Low-level Resources +----------------------------- + +You can use :py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` +to access any child of this construct by it's construct ID. By convention, the "main" +resource of any AWS Construct will always be called ``"Resource"``. + +The following example shows how to access the underlying S3 bucket resource +given an :py:class:`s3.Bucket <@aws-cdk/s3.Bucket>` construct: + +.. code-block:: ts + + // let's create an AWS bucket construct + const bucket = new s3.Bucket(this, 'MyBucket'); + + // we use our "knoweledge" that the main construct is called "Resource" and + // that it's actual type is s3.cloudformation.BucketResource; const + bucketResource = bucket.findResource('Resource') as s3.cloudformation.BucketResource; + +At this point, ``bucketResource`` represents the low-level CloudFormation resource of type +:py:class:`s3.cloudformation.BucketResource <@aws-cdk/aws-s3.cloudformation.BucketResource>` +encapsulated by our bucket. + +:py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` will fail +if the child could not be located, which means that if the underlying L2 changes +the IDs or structure for some reason, synthesis will fail. + +It is also possible to use :py:meth:`construct.children <@aws-cdk/cdk.Construct.children>` for more +advanced queries. For example, we can look for a child that has a certain CloudFormation resource +type: + +.. code-block:: ts + + const bucketResource = + bucket.children.find(c => (c as cdk.Resource).resourceType === 'AWS::S3::Bucket') + as s3.cloudformation.BucketResource; + +From that point, users are interacting with the strong-typed L1 resources (which +extend :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>`), so we will look into +how to extend their surface area to support the various requirements. + +Resource Options +---------------- + +:py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` has a few facilities for +setting resource options such as ``Metadata``, ``DependsOn``, etc. + +For example, this code: + +.. code-block:: ts + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + + bucketResource.options.metadata = { MetadataKey: 'MetadataValue' }; + bucketResource.options.updatePolicy = { + autoScalingRollingUpdate: { + pauseTime: '390' + } + }; + + bucketResource.addDependency(otherBucket.findChild('Resource') as cdk.Resource); + +Will synthesize the following template: + +.. code-block:: json + + { + "Type": "AWS::S3::Bucket", + "DependsOn": [ "Other34654A52" ], + "UpdatePolicy": { + "AutoScalingRollingUpdate": { + "PauseTime": "390" + } + }, + "Metadata": { + "MetadataKey": "MetadataValue" + } + } + +Overriding Resource Properties +------------------------------ + +Each low-level resource in the CDK has a strongly-typed property called +``propertyOverrides``. It allows users to apply overrides that adhere to the +CloudFormation schema of the resource, and use code-completion and +type-checking. + +You will normally use this mechanism when a certain feature is available at the +CloudFormation layer but was not exposed by the AWS Construct. + +The following example sets a bucket's analytics configuration: + +.. code-block:: ts + + bucketResource.propertyOverrides.analyticsConfigurations = [ + { + id: 'config1', + storageClassAnalysis: { + dataExport: { + outputSchemaVersion: '1', + destination: { + format: 'html', + bucketArn: otherBucket.bucketArn // use tokens freely + } + } + } + } + ]; + +Raw Overrides +------------- + +In cases the strongly-typed overrides are not sufficient, or, for example, if +the schema defined in CloudFormation is not up-to-date, the method +:py:meth:`cdk.Resource.addOverride(path, value) <@aws-cdk/cdk.Resource.addOverride>` +can be used to define an override that will by applied to the resource +definition during synthesis. + +For example: + +.. code-block:: ts + + // define an override at the resource definition root + bucketResource.addOverride('Transform', 'Boom'); + + // define an override for a property (both are equivalent operations): + bucketResource.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus'); + bucketResource.addOverride('Properties.VersioningConfiguration.Status', 'NewStatus'); + + // use dot-notation to define overrides in complex structures which will be merged + // with the values set by the higher-level construct + bucketResource.addPropertyOverride('LoggingConfiguration.DestinationBucketName', otherBucket.bucketName); + + // it is also possible to assign a `null` value if this is your thing + bucketResource.addPropertyOverride('Foo.Bar', null); + +Will synthesize to: + +.. code-block:: json + + { + "Type": "AWS::S3::Bucket", + "Properties": { + "Foo": { + "Bar": null + }, + "VersioningConfiguration": { + "Status": "NewStatus" + }, + "LoggingConfiguration": { + "DestinationBucketName": { + "Ref": "Other34654A52" + } + } + }, + "Transform": "Boom" + } + +Use ``undefined``, :py:meth:`cdk.Resource.addDeletionOverride <@aws-cdk/cdk.Resource.addDeletionOverride>` +or :py:meth:`cdk.Resource.addPropertyDeletionOverride <@aws-cdk/cdk.Resource.addPropertyDeletionOverride>` +to delete values: + +.. code-block:: ts + + const bucket = new s3.Bucket(this, 'MyBucket', { + versioned: true, + encryption: s3.BucketEncryption.KmsManaged + }); + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + bucketResource.addPropertyOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.EncryptEverythingAndAlways', true); + bucketResource.addPropertyDeletionOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.ServerSideEncryptionByDefault'); + +Will synthesize to: + +.. code-block:: json + + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "EncryptEverythingAndAlways": true + } + ] + }, + "VersioningConfiguration": { + "Status": "Enabled" + } + } + } + +Directly Defining CloudFormation Resources +------------------------------------------- + +It is also possible to explicitly define CloudFormation resources in your stack. +To that end, simply instantiate one of the constructs under the +``cloudformation`` namespace of the dedicated library. + +.. code-block:: ts + + new s3.cloudformation.BucketResource(this, 'MyBucket', { + analyticsConfigurations: [ + // ... + ] + }); + +In the rare case where you wish to define a resource that doesn't have a +corresponding ``cloudformation`` class (i.e. a new resource that was not yet +published in the CloudFormation resource specification), it is possible to +simply instantiate the :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` +object: + +.. code-block:: ts + + new cdk.Resource(this, 'MyBucket', { + type: 'AWS::S3::Bucket', + properties: { + AnalyticsConfiguration: [ /* ... */ ] // note the PascalCase here + } + }); -Every module in the AWS Construct Library includes a ``cloudformation`` namespace which contains -low-level constructs which represent the low-level AWS CloudFormation semantics of this service. -See :py:doc:`cloudformation` for details. diff --git a/examples/cdk-examples-typescript/resource-overrides/cdk.json b/examples/cdk-examples-typescript/resource-overrides/cdk.json new file mode 100644 index 0000000000000..34ce3ae7cb680 --- /dev/null +++ b/examples/cdk-examples-typescript/resource-overrides/cdk.json @@ -0,0 +1,22 @@ +{ + "app": "node index", + "context": { + "availability-zones:993655754359:us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ], + "availability-zones:585695036304:us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" + ], + "ssm:585695036304:us-east-1:/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2": "ami-0ff8a91507f77f867" + } +} diff --git a/examples/cdk-examples-typescript/resource-overrides/index.ts b/examples/cdk-examples-typescript/resource-overrides/index.ts new file mode 100644 index 0000000000000..8d7b786c89022 --- /dev/null +++ b/examples/cdk-examples-typescript/resource-overrides/index.ts @@ -0,0 +1,118 @@ +import autoscaling = require('@aws-cdk/aws-autoscaling'); +import ec2 = require('@aws-cdk/aws-ec2'); +import s3 = require('@aws-cdk/aws-s3'); +import cdk = require('@aws-cdk/cdk'); +import assert = require('assert'); + +class ResourceOverridesExample extends cdk.Stack { + constructor(parent: cdk.App, id: string) { + super(parent, id); + + const otherBucket = new s3.Bucket(this, 'Other'); + + const bucket = new s3.Bucket(this, 'MyBucket', { + versioned: true, + encryption: s3.BucketEncryption.KmsManaged + }); + + const bucketResource2 = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + bucketResource2.addPropertyOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.EncryptEverythingAndAlways', true); + bucketResource2.addPropertyDeletionOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.ServerSideEncryptionByDefault'); + + return; + + // + // Accessing the L1 bucket resource from an L2 bucket + // + + const bucketResource = bucket.findChild('Resource') as s3.cloudformation.BucketResource; + const anotherWay = bucket.children.find(c => (c as cdk.Resource).resourceType === 'AWS::S3::Bucket') as s3.cloudformation.BucketResource; + assert.equal(bucketResource, anotherWay); + + // + // This is how to specify resource options such as dependencies, metadata, update policy + // + + bucketResource.addDependency(otherBucket.findChild('Resource') as cdk.Resource); + bucketResource.options.metadata = { MetadataKey: 'MetadataValue' }; + bucketResource.options.updatePolicy = { + autoScalingRollingUpdate: { + pauseTime: '390' + } + }; + + // + // This is how to specify "raw" overrides at the __resource__ level + // + + bucketResource.addOverride('Type', 'AWS::S3::Bucketeer'); // even "Type" can be overridden + bucketResource.addOverride('Transform', 'Boom'); + bucketResource.addOverride('Properties.CorsConfiguration', { + Custom: 123, + Bar: [ 'A', 'B' ] + }); + + // addPropertyOverrides simply allows you to omit the "Properties." prefix + bucketResource.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus'); + bucketResource.addPropertyOverride('Foo', null); + bucketResource.addPropertyOverride('Token', otherBucket.bucketArn); // use tokens + bucketResource.addPropertyOverride('LoggingConfiguration.DestinationBucketName', otherBucket.bucketName); + + // + // It is also possible to request a deletion of a value by either assigning + // `undefined` (in supported languages) or use the `addDeletionOverride` method + // + + bucketResource.addDeletionOverride('Metadata'); + bucketResource.addPropertyDeletionOverride('CorsConfiguration.Bar'); + + // + // It is also possible to specify overrides via a strong-typed property + // bag called `propertyOverrides` + // + + bucketResource.propertyOverrides.analyticsConfigurations = [ + { + id: 'config1', + storageClassAnalysis: { + dataExport: { + outputSchemaVersion: '1', + destination: { + format: 'html', + bucketArn: otherBucket.bucketArn // use tokens freely + } + } + } + } + ]; + + bucketResource.propertyOverrides.corsConfiguration = { + corsRules: [ + { + allowedMethods: [ 'GET' ], + allowedOrigins: [ '*' ] + } + ] + }; + + const vpc = new ec2.VpcNetwork(this, 'VPC', { maxAZs: 1 }); + const asg = new autoscaling.AutoScalingGroup(this, 'ASG', { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.M4, ec2.InstanceSize.XLarge), + machineImage: new ec2.AmazonLinuxImage(), + vpc + }); + + // + // The default child resource is called `Resource`, but secondary resources, such as + // an Auto Scaling Group's launch configuration might have a different ID. You will likely + // need to consule the codebase or use the `.map.find` method above + // + + const lc = asg.findChild('LaunchConfig') as autoscaling.cloudformation.LaunchConfigurationResource; + lc.addPropertyOverride('Foo.Bar', 'Hello'); + } +} + +const app = new cdk.App(process.argv); +new ResourceOverridesExample(app, 'resource-overrides'); +process.stdout.write(app.run()); \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index 1b050445116b7..e23e79718700f 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -48,10 +48,29 @@ export class Resource extends Referenceable { public readonly resourceType: string; /** - * AWS resource properties + * AWS resource property overrides. + * + * During synthesis, the method "renderProperties(this.overrides)" is called + * with this object, and merged on top of the output of + * "renderProperties(this.properties)". + * + * Derived classes should expose a strongly-typed version of this object as + * a public property called `propertyOverrides`. + */ + protected readonly untypedPropertyOverrides: any = { }; + + /** + * AWS resource properties. + * + * This object is rendered via a call to "renderProperties(this.properties)". */ protected readonly properties: any; + /** + * An object to be merged on top of the entire resource definition. + */ + private readonly rawOverrides: any = { }; + private dependsOn = new Array(); /** @@ -93,23 +112,86 @@ export class Resource extends Referenceable { this.dependsOn.push(...other); } + /** + * Adds an override to the synthesized CloudFormation resource. To add a + * property override, either use `addPropertyOverride` or prefix `path` with + * "Properties." (i.e. `Properties.TopicName`). + * + * @param path The path of the property, you can use dot notation to + * override values in complex types. Any intermdediate keys + * will be created as needed. + * @param value The value. Could be primitive or complex. + */ + public addOverride(path: string, value: any) { + const parts = path.split('.'); + let curr: any = this.rawOverrides; + + while (parts.length > 1) { + const key = parts.shift()!; + + // if we can't recurse further or the previous value is not an + // object overwrite it with an object. + const isObject = curr[key] != null && typeof(curr[key]) === 'object' && !Array.isArray(curr[key]); + if (!isObject) { + curr[key] = { }; + } + + curr = curr[key]; + } + + const lastKey = parts.shift()!; + curr[lastKey] = value; + } + + /** + * Syntactic sugar for `addOverride(path, undefined)`. + * @param path The path of the value to delete + */ + public addDeletionOverride(path: string) { + this.addOverride(path, undefined); + } + + /** + * Adds an override to a resource property. + * + * Syntactic sugar for `addOverride("Properties.<...>", value)`. + * + * @param propertyPath The path of the property + * @param value The value + */ + public addPropertyOverride(propertyPath: string, value: any) { + this.addOverride(`Properties.${propertyPath}`, value); + } + + /** + * Adds an override that deletes the value of a property from the resource definition. + * @param propertyPath The path to the property. + */ + public addPropertyDeletionOverride(propertyPath: string) { + this.addPropertyOverride(propertyPath, undefined); + } + /** * Emits CloudFormation for this resource. */ public toCloudFormation(): object { try { + + const properties = ignoreEmpty(this.renderProperties(this.properties)) || { }; + const overrides = ignoreEmpty(this.renderProperties(this.untypedPropertyOverrides)) || { }; + return { Resources: { - [this.logicalId]: { + [this.logicalId]: deepMerge({ Type: this.resourceType, - Properties: ignoreEmpty(this.renderProperties()), + Properties: ignoreEmpty(deepMerge(properties, overrides)), DependsOn: ignoreEmpty(this.renderDependsOn()), CreationPolicy: capitalizePropertyNames(this.options.creationPolicy), UpdatePolicy: capitalizePropertyNames(this.options.updatePolicy), DeletionPolicy: capitalizePropertyNames(this.options.deletionPolicy), Metadata: ignoreEmpty(this.options.metadata), Condition: this.options.condition && this.options.condition.logicalId - } + }, this.rawOverrides) } }; } catch (e) { @@ -124,9 +206,8 @@ export class Resource extends Referenceable { } } - protected renderProperties(): { [key: string]: any } { - // FIXME: default implementation is not great, it should throw, but it avoids breaking all unit tests for now. - return this.properties; + protected renderProperties(properties: any): { [key: string]: any } { + return properties; } private renderDependsOn() { @@ -192,3 +273,40 @@ export interface ResourceOptions { */ metadata?: { [key: string]: any }; } + +/** + * Merges `source` into `target`, overriding any existing values. + * `null`s will cause a value to be deleted. + */ +export function deepMerge(target: any, source: any) { + if (typeof(source) !== 'object' || typeof(target) !== 'object') { + throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); + } + + for (const key of Object.keys(source)) { + const value = source[key]; + if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { + // if the value at the target is not an object, override it with an + // object so we can continue the recursion + if (typeof(target[key]) !== 'object') { + target[key] = { }; + } + + deepMerge(target[key], value); + + // if the result of the merge is an empty object, it's because the + // eventual value we assigned is `undefined`, and there are no + // sibling concrete values alongside, so we can delete this tree. + const output = target[key]; + if (typeof(output) === 'object' && Object.keys(output).length === 0) { + delete target[key]; + } + } else if (value === undefined) { + delete target[key]; + } else { + target[key] = value; + } + } + + return target; +} diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index 1b6f81d89d2a9..fc79e14ceda29 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -347,6 +347,181 @@ export = { test.deepEqual(resolve(r.ref), { Ref: 'MyResource' }); test.done(); + }, + + 'overrides': { + 'addOverride(p, v) allows assigning arbitrary values to synthesized resource definitions'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); + + // WHEN + r.addOverride('Type', 'YouCanEvenOverrideTheType'); + r.addOverride('Metadata', { Key: 12 }); + r.addOverride('Use.Dot.Notation', 'To create subtrees'); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'YouCanEvenOverrideTheType', + Use: { Dot: { Notation: 'To create subtrees' } }, + Metadata: { Key: 12 } } } }); + + test.done(); + }, + + 'addOverride(p, null) will assign an "null" value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + } + } + } + }); + + // WHEN + r.addOverride('Properties.Hello.World.Value2', null); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); + + test.done(); + }, + + 'addOverride(p, undefined) can be used to delete a value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + } + } + } + }); + + // WHEN + r.addOverride('Properties.Hello.World.Value2', undefined); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + + test.done(); + }, + + 'addOverride(p, undefined) will not create empty trees'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); + + // WHEN + r.addPropertyOverride('Tree.Exists', 42); + r.addPropertyOverride('Tree.Does.Not.Exist', undefined); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Tree: { Exists: 42 } } } } }); + + test.done(); + }, + + 'addDeletionOverride(p) and addPropertyDeletionOverride(pp) are sugar `undefined`'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + Value3: [ 'foo', 'bar' ] + } + } + } + }); + + // WHEN + r.addDeletionOverride('Properties.Hello.World.Value2'); + r.addPropertyDeletionOverride('Hello.World.Value3'); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); + + test.done(); + }, + + 'addOverride(p, v) will overwrite any non-objects along the path'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: 42 + } + } + }); + + // WHEN + r.addOverride('Properties.Override1', [ 'Hello', 123 ]); + r.addOverride('Properties.Override1.Override2', { Heyy: [ 1 ] }); + r.addOverride('Properties.Hello.World.Foo.Bar', 42); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: + { Hello: { World: { Foo: { Bar: 42 } } }, + Override1: { + Override2: { Heyy: [ 1] } + } } } } }); + test.done(); + }, + + 'addPropertyOverride(pp, v) is a sugar for overriding properties'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { Hello: { World: 42 } } + }); + + // WHEN + r.addPropertyOverride('Hello.World', { Hey: 'Jude' }); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); + test.done(); + } } }; diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 52c9a6e88f40c..1cbc62b20363e 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -322,8 +322,12 @@ export default class CodeGenerator { * Since resolve() deep-resolves, we only need to do this once. */ private emitCloudFormationPropertiesOverride(propsType: genspec.CodeName) { - this.code.openBlock('protected renderProperties(): { [key: string]: any } '); - this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(this.properties));`); + this.code.openBlock(`public get propertyOverrides(): ${propsType.className}`); + this.code.line(`return this.untypedPropertyOverrides;`); + this.code.closeBlock(); + + this.code.openBlock('protected renderProperties(properties: any): { [key: string]: any } '); + this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(properties));`); this.code.closeBlock(); } From 465a15623c760218ae31f1abe93361e6f9fc7729 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 21:57:46 +0300 Subject: [PATCH 2/6] CR fixes --- docs/src/aws-construct-lib.rst | 78 +++++++++++++++++----------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/src/aws-construct-lib.rst b/docs/src/aws-construct-lib.rst index dbcaaa2b865f1..13430ad08eeb5 100644 --- a/docs/src/aws-construct-lib.rst +++ b/docs/src/aws-construct-lib.rst @@ -106,15 +106,15 @@ were part of your app. Access the AWS CloudFormation Layer =================================== -This topic discusses ways to work around gaps and missing features at the AWS -Construct Library. We also refer to this as an "escape hatch", as it allows -users to escape from the abstraction boundary defined by the AWS Construct and -patch the underlying resources. +This topic discusses ways to directly modify the underlying CloudFormation +resources at the AWS Construct Library. We also call this technique an "escape +hatch", as it allows users to "escape" from the abstraction boundary defined by +the AWS Construct and patch the underlying resources. .. important:: - **We generally do not recommend this method, as it breaks the abstraction - layer and can potentially produce invalid results**. + **We do not recommend this method, as it breaks the abstraction layer and + might produce unexpected results**. Furthermore, the internal implementation of an AWS construct is not part of the API compatibility guarantees that we can make. This means that updates to @@ -128,22 +128,22 @@ encapsulates the :py:class:`@aws-cdk/aws-s3.cloudformation.BucketResource`. When a stack that includes an AWS construct is synthesized, the CloudFormation definition of the underlying resources are included in the resulting template. -In the fullness of time, the APIs provided by AWS constructs are expected to -support all the services and capabilities offered by AWS, but we are aware that -the library still has many gaps both at the service level (some services simply -don't have any constructs yet) and at the resource level (an AWS construct -exists, but some features are missing). +Eventually, the APIs provided by AWS constructs are expected to support all the +services and capabilities offered by AWS, but we are aware that the library +still has many gaps both at the service level (some services don't have any +constructs yet) and at the resource level (an AWS construct exists, but some +features are missing). .. note:: If you encounter a missing capability in the AWS Construct Library, whether - it is an entire library, a specific resource or a feature, please consider to + it is an entire library, a specific resource or a feature, `raise an issue `_ on GitHub, and letting us know. This topic covers the following use cases: -- How to access CloudFormation the internal resources encapsulated by an AWS construct +- How to access the low-level CloudFormation resources encapsulated by an AWS construct - How to specify resource options such as metadata, dependencies on resources - How to add overrides to a CloudFormation resource and property definitions - How to directly define low-level CloudFormation resources without an AWS construct @@ -155,8 +155,8 @@ Accessing Low-level Resources ----------------------------- You can use :py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` -to access any child of this construct by it's construct ID. By convention, the "main" -resource of any AWS Construct will always be called ``"Resource"``. +to access any child of this construct by its construct ID. By convention, the "main" +resource of any AWS Construct is called ``"Resource"``. The following example shows how to access the underlying S3 bucket resource given an :py:class:`s3.Bucket <@aws-cdk/s3.Bucket>` construct: @@ -166,17 +166,17 @@ given an :py:class:`s3.Bucket <@aws-cdk/s3.Bucket>` construct: // let's create an AWS bucket construct const bucket = new s3.Bucket(this, 'MyBucket'); - // we use our "knoweledge" that the main construct is called "Resource" and + // we use our knowledge that the main construct is called "Resource" and // that it's actual type is s3.cloudformation.BucketResource; const - bucketResource = bucket.findResource('Resource') as s3.cloudformation.BucketResource; + const bucketResource = bucket.findResource('Resource') as s3.cloudformation.BucketResource; At this point, ``bucketResource`` represents the low-level CloudFormation resource of type :py:class:`s3.cloudformation.BucketResource <@aws-cdk/aws-s3.cloudformation.BucketResource>` encapsulated by our bucket. :py:meth:`construct.findChild(id) <@aws-cdk/cdk.Construct.findChild>` will fail -if the child could not be located, which means that if the underlying L2 changes -the IDs or structure for some reason, synthesis will fail. +if the child could not be located, which means that if the underlying |l2| changes +the IDs or structure for some reason, synthesis fails. It is also possible to use :py:meth:`construct.children <@aws-cdk/cdk.Construct.children>` for more advanced queries. For example, we can look for a child that has a certain CloudFormation resource @@ -188,9 +188,10 @@ type: bucket.children.find(c => (c as cdk.Resource).resourceType === 'AWS::S3::Bucket') as s3.cloudformation.BucketResource; -From that point, users are interacting with the strong-typed L1 resources (which -extend :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>`), so we will look into -how to extend their surface area to support the various requirements. +From that point, users are interacting with CloudFormation resource classes +(which extend :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>`), so we will look +into how to use their APIs in order to modify the behavior of the AWS construct +at hand. Resource Options ---------------- @@ -213,7 +214,7 @@ For example, this code: bucketResource.addDependency(otherBucket.findChild('Resource') as cdk.Resource); -Will synthesize the following template: +Synthesizes the following template: .. code-block:: json @@ -239,7 +240,7 @@ CloudFormation schema of the resource, and use code-completion and type-checking. You will normally use this mechanism when a certain feature is available at the -CloudFormation layer but was not exposed by the AWS Construct. +CloudFormation layer but is not exposed by the AWS Construct. The following example sets a bucket's analytics configuration: @@ -273,8 +274,9 @@ For example: .. code-block:: ts - // define an override at the resource definition root - bucketResource.addOverride('Transform', 'Boom'); + // define an override at the resource definition root, you can even modify the "Type" + // of the resource if needed. + bucketResource.addOverride('Type', 'AWS::S3::SpecialBucket'); // define an override for a property (both are equivalent operations): bucketResource.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus'); @@ -284,15 +286,15 @@ For example: // with the values set by the higher-level construct bucketResource.addPropertyOverride('LoggingConfiguration.DestinationBucketName', otherBucket.bucketName); - // it is also possible to assign a `null` value if this is your thing + // it is also possible to assign a `null` value bucketResource.addPropertyOverride('Foo.Bar', null); -Will synthesize to: +Synthesizes to: .. code-block:: json { - "Type": "AWS::S3::Bucket", + "Type": "AWS::S3::SpecialBucket", "Properties": { "Foo": { "Bar": null @@ -305,8 +307,7 @@ Will synthesize to: "Ref": "Other34654A52" } } - }, - "Transform": "Boom" + } } Use ``undefined``, :py:meth:`cdk.Resource.addDeletionOverride <@aws-cdk/cdk.Resource.addDeletionOverride>` @@ -324,7 +325,7 @@ to delete values: bucketResource.addPropertyOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.EncryptEverythingAndAlways', true); bucketResource.addPropertyDeletionOverride('BucketEncryption.ServerSideEncryptionConfiguration.0.ServerSideEncryptionByDefault'); -Will synthesize to: +Synthesizes to: .. code-block:: json @@ -348,8 +349,8 @@ Directly Defining CloudFormation Resources ------------------------------------------- It is also possible to explicitly define CloudFormation resources in your stack. -To that end, simply instantiate one of the constructs under the -``cloudformation`` namespace of the dedicated library. +To that end, instantiate one of the constructs under the ``cloudformation`` +namespace of the dedicated library. .. code-block:: ts @@ -359,11 +360,10 @@ To that end, simply instantiate one of the constructs under the ] }); -In the rare case where you wish to define a resource that doesn't have a -corresponding ``cloudformation`` class (i.e. a new resource that was not yet -published in the CloudFormation resource specification), it is possible to -simply instantiate the :py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` -object: +In the rare case where you want to define a resource that doesn't have a +corresponding ``cloudformation`` class (such as a new resource that was not yet +published in the CloudFormation resource specification), you can instantiate the +:py:class:`cdk.Resource <@aws-cdk/cdk.Resource>` object: .. code-block:: ts From 8e6648f18b646b6bbeb8cfcfe4dc18f771a6fac9 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 21:59:33 +0300 Subject: [PATCH 3/6] Prepare for merge --- .../cdk/lib/cloudformation/resource.ts | 540 ++++---- .../cdk/test/cloudformation/test.resource.ts | 992 +++++++-------- tools/cfn2ts/lib/codegen.ts | 1090 ++++++++--------- 3 files changed, 1311 insertions(+), 1311 deletions(-) diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index e23e79718700f..978967667726a 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -6,272 +6,272 @@ import { CreationPolicy, DeletionPolicy, UpdatePolicy } from './resource-policy' import { IDependable, Referenceable, StackElement } from './stack'; export interface ResourceProps { - /** - * CloudFormation resource type. - */ - type: string; - - /** - * CloudFormation properties. - */ - properties?: any; + /** + * CloudFormation resource type. + */ + type: string; + + /** + * CloudFormation properties. + */ + properties?: any; } /** * Represents a CloudFormation resource. */ export class Resource extends Referenceable { - /** - * A decoration used to create a CloudFormation attribute property. - * @param customName Custom name for the attribute (default is the name of the property) - * NOTE: we return "any" here to satistfy jsii, which doesn't support lambdas. - */ - public static attribute(customName?: string): any { - return (prototype: any, key: string) => { - const name = customName || key; - Object.defineProperty(prototype, key, { - get() { - return (this as any).getAtt(name); - } - }); - }; - } - - /** - * Options for this resource, such as condition, update policy etc. - */ - public readonly options: ResourceOptions = {}; - - /** - * AWS resource type. - */ - public readonly resourceType: string; - - /** - * AWS resource property overrides. - * - * During synthesis, the method "renderProperties(this.overrides)" is called - * with this object, and merged on top of the output of - * "renderProperties(this.properties)". - * - * Derived classes should expose a strongly-typed version of this object as - * a public property called `propertyOverrides`. - */ - protected readonly untypedPropertyOverrides: any = { }; - - /** - * AWS resource properties. - * - * This object is rendered via a call to "renderProperties(this.properties)". - */ - protected readonly properties: any; - - /** - * An object to be merged on top of the entire resource definition. - */ - private readonly rawOverrides: any = { }; - - private dependsOn = new Array(); - - /** - * Creates a resource construct. - * @param resourceType The CloudFormation type of this resource (e.g. AWS::DynamoDB::Table) - */ - constructor(parent: Construct, name: string, props: ResourceProps) { - super(parent, name); - - if (!props.type) { - throw new Error('The `type` property is required'); - } - - this.resourceType = props.type; - this.properties = props.properties || { }; - - // 'name' is a special property included for resource constructs and passed - // as 'name', but we don't want it to be serialized into the template. - if (this.properties.name) { - delete this.properties.name; - } - } - - /** - * Returns a token for an runtime attribute of this resource. - * Ideally, use generated attribute accessors (e.g. `resource.arn`), but this can be used for future compatibility - * in case there is no generated attribute. - * @param attributeName The name of the attribute. - */ - public getAtt(attributeName: string) { - return new CloudFormationToken({ 'Fn::GetAtt': [this.logicalId, attributeName] }, `${this.logicalId}.${attributeName}`); - } - - /** - * Adds a dependency on another resource. - * @param other The other resource. - */ - public addDependency(...other: IDependable[]) { - this.dependsOn.push(...other); - } - - /** - * Adds an override to the synthesized CloudFormation resource. To add a - * property override, either use `addPropertyOverride` or prefix `path` with - * "Properties." (i.e. `Properties.TopicName`). - * - * @param path The path of the property, you can use dot notation to - * override values in complex types. Any intermdediate keys - * will be created as needed. - * @param value The value. Could be primitive or complex. - */ - public addOverride(path: string, value: any) { - const parts = path.split('.'); - let curr: any = this.rawOverrides; - - while (parts.length > 1) { - const key = parts.shift()!; - - // if we can't recurse further or the previous value is not an - // object overwrite it with an object. - const isObject = curr[key] != null && typeof(curr[key]) === 'object' && !Array.isArray(curr[key]); - if (!isObject) { - curr[key] = { }; - } - - curr = curr[key]; + /** + * A decoration used to create a CloudFormation attribute property. + * @param customName Custom name for the attribute (default is the name of the property) + * NOTE: we return "any" here to satistfy jsii, which doesn't support lambdas. + */ + public static attribute(customName?: string): any { + return (prototype: any, key: string) => { + const name = customName || key; + Object.defineProperty(prototype, key, { + get() { + return (this as any).getAtt(name); } - - const lastKey = parts.shift()!; - curr[lastKey] = value; + }); + }; + } + + /** + * Options for this resource, such as condition, update policy etc. + */ + public readonly options: ResourceOptions = {}; + + /** + * AWS resource type. + */ + public readonly resourceType: string; + + /** + * AWS resource property overrides. + * + * During synthesis, the method "renderProperties(this.overrides)" is called + * with this object, and merged on top of the output of + * "renderProperties(this.properties)". + * + * Derived classes should expose a strongly-typed version of this object as + * a public property called `propertyOverrides`. + */ + protected readonly untypedPropertyOverrides: any = { }; + + /** + * AWS resource properties. + * + * This object is rendered via a call to "renderProperties(this.properties)". + */ + protected readonly properties: any; + + /** + * An object to be merged on top of the entire resource definition. + */ + private readonly rawOverrides: any = { }; + + private dependsOn = new Array(); + + /** + * Creates a resource construct. + * @param resourceType The CloudFormation type of this resource (e.g. AWS::DynamoDB::Table) + */ + constructor(parent: Construct, name: string, props: ResourceProps) { + super(parent, name); + + if (!props.type) { + throw new Error('The `type` property is required'); } - /** - * Syntactic sugar for `addOverride(path, undefined)`. - * @param path The path of the value to delete - */ - public addDeletionOverride(path: string) { - this.addOverride(path, undefined); - } + this.resourceType = props.type; + this.properties = props.properties || { }; - /** - * Adds an override to a resource property. - * - * Syntactic sugar for `addOverride("Properties.<...>", value)`. - * - * @param propertyPath The path of the property - * @param value The value - */ - public addPropertyOverride(propertyPath: string, value: any) { - this.addOverride(`Properties.${propertyPath}`, value); + // 'name' is a special property included for resource constructs and passed + // as 'name', but we don't want it to be serialized into the template. + if (this.properties.name) { + delete this.properties.name; } - - /** - * Adds an override that deletes the value of a property from the resource definition. - * @param propertyPath The path to the property. - */ - public addPropertyDeletionOverride(propertyPath: string) { - this.addPropertyOverride(propertyPath, undefined); + } + + /** + * Returns a token for an runtime attribute of this resource. + * Ideally, use generated attribute accessors (e.g. `resource.arn`), but this can be used for future compatibility + * in case there is no generated attribute. + * @param attributeName The name of the attribute. + */ + public getAtt(attributeName: string) { + return new CloudFormationToken({ 'Fn::GetAtt': [this.logicalId, attributeName] }, `${this.logicalId}.${attributeName}`); + } + + /** + * Adds a dependency on another resource. + * @param other The other resource. + */ + public addDependency(...other: IDependable[]) { + this.dependsOn.push(...other); + } + + /** + * Adds an override to the synthesized CloudFormation resource. To add a + * property override, either use `addPropertyOverride` or prefix `path` with + * "Properties." (i.e. `Properties.TopicName`). + * + * @param path The path of the property, you can use dot notation to + * override values in complex types. Any intermdediate keys + * will be created as needed. + * @param value The value. Could be primitive or complex. + */ + public addOverride(path: string, value: any) { + const parts = path.split('.'); + let curr: any = this.rawOverrides; + + while (parts.length > 1) { + const key = parts.shift()!; + + // if we can't recurse further or the previous value is not an + // object overwrite it with an object. + const isObject = curr[key] != null && typeof(curr[key]) === 'object' && !Array.isArray(curr[key]); + if (!isObject) { + curr[key] = { }; + } + + curr = curr[key]; } - /** - * Emits CloudFormation for this resource. - */ - public toCloudFormation(): object { - try { - - const properties = ignoreEmpty(this.renderProperties(this.properties)) || { }; - const overrides = ignoreEmpty(this.renderProperties(this.untypedPropertyOverrides)) || { }; - - return { - Resources: { - [this.logicalId]: deepMerge({ - Type: this.resourceType, - Properties: ignoreEmpty(deepMerge(properties, overrides)), - DependsOn: ignoreEmpty(this.renderDependsOn()), - CreationPolicy: capitalizePropertyNames(this.options.creationPolicy), - UpdatePolicy: capitalizePropertyNames(this.options.updatePolicy), - DeletionPolicy: capitalizePropertyNames(this.options.deletionPolicy), - Metadata: ignoreEmpty(this.options.metadata), - Condition: this.options.condition && this.options.condition.logicalId - }, this.rawOverrides) - } - }; - } catch (e) { - // Change message - e.message = `While synthesizing ${this.path}: ${e.message}`; - // Adjust stack trace (make it look like node built it, too...) - const creationStack = ['--- resource created at ---', ...this.creationStackTrace].join('\n at '); - const problemTrace = e.stack.substr(e.stack.indexOf(e.message) + e.message.length); - e.stack = `${e.message}\n ${creationStack}\n --- problem discovered at ---${problemTrace}`; - // Re-throw - throw e; + const lastKey = parts.shift()!; + curr[lastKey] = value; + } + + /** + * Syntactic sugar for `addOverride(path, undefined)`. + * @param path The path of the value to delete + */ + public addDeletionOverride(path: string) { + this.addOverride(path, undefined); + } + + /** + * Adds an override to a resource property. + * + * Syntactic sugar for `addOverride("Properties.<...>", value)`. + * + * @param propertyPath The path of the property + * @param value The value + */ + public addPropertyOverride(propertyPath: string, value: any) { + this.addOverride(`Properties.${propertyPath}`, value); + } + + /** + * Adds an override that deletes the value of a property from the resource definition. + * @param propertyPath The path to the property. + */ + public addPropertyDeletionOverride(propertyPath: string) { + this.addPropertyOverride(propertyPath, undefined); + } + + /** + * Emits CloudFormation for this resource. + */ + public toCloudFormation(): object { + try { + + const properties = ignoreEmpty(this.renderProperties(this.properties)) || { }; + const overrides = ignoreEmpty(this.renderProperties(this.untypedPropertyOverrides)) || { }; + + return { + Resources: { + [this.logicalId]: deepMerge({ + Type: this.resourceType, + Properties: ignoreEmpty(deepMerge(properties, overrides)), + DependsOn: ignoreEmpty(this.renderDependsOn()), + CreationPolicy: capitalizePropertyNames(this.options.creationPolicy), + UpdatePolicy: capitalizePropertyNames(this.options.updatePolicy), + DeletionPolicy: capitalizePropertyNames(this.options.deletionPolicy), + Metadata: ignoreEmpty(this.options.metadata), + Condition: this.options.condition && this.options.condition.logicalId + }, this.rawOverrides) } + }; + } catch (e) { + // Change message + e.message = `While synthesizing ${this.path}: ${e.message}`; + // Adjust stack trace (make it look like node built it, too...) + const creationStack = ['--- resource created at ---', ...this.creationStackTrace].join('\n at '); + const problemTrace = e.stack.substr(e.stack.indexOf(e.message) + e.message.length); + e.stack = `${e.message}\n ${creationStack}\n --- problem discovered at ---${problemTrace}`; + // Re-throw + throw e; } + } - protected renderProperties(properties: any): { [key: string]: any } { - return properties; + protected renderProperties(properties: any): { [key: string]: any } { + return properties; + } + + private renderDependsOn() { + const logicalIDs = new Set(); + for (const d of this.dependsOn) { + addDependency(d); } - private renderDependsOn() { - const logicalIDs = new Set(); - for (const d of this.dependsOn) { - addDependency(d); - } + return Array.from(logicalIDs); - return Array.from(logicalIDs); - - function addDependency(d: IDependable) { - d.dependencyElements.forEach(dep => { - const logicalId = (dep as StackElement).logicalId; - if (logicalId) { - logicalIDs.add(logicalId); - } - }); - - // break if dependencyElements include only 'd', which means we reached a terminal. - if (d.dependencyElements.length === 1 && d.dependencyElements[0] === d) { - return; - } else { - d.dependencyElements.forEach(dep => addDependency(dep)); - } + function addDependency(d: IDependable) { + d.dependencyElements.forEach(dep => { + const logicalId = (dep as StackElement).logicalId; + if (logicalId) { + logicalIDs.add(logicalId); } + }); + + // break if dependencyElements include only 'd', which means we reached a terminal. + if (d.dependencyElements.length === 1 && d.dependencyElements[0] === d) { + return; + } else { + d.dependencyElements.forEach(dep => addDependency(dep)); + } } + } } export interface ResourceOptions { - /** - * A condition to associate with this resource. This means that only if the condition evaluates to 'true' when the stack - * is deployed, the resource will be included. This is provided to allow CDK projects to produce legacy templates, but noramlly - * there is no need to use it in CDK projects. - */ - condition?: Condition; - - /** - * Associate the CreationPolicy attribute with a resource to prevent its status from reaching create complete until - * AWS CloudFormation receives a specified number of success signals or the timeout period is exceeded. To signal a - * resource, you can use the cfn-signal helper script or SignalResource API. AWS CloudFormation publishes valid signals - * to the stack events so that you track the number of signals sent. - */ - creationPolicy?: CreationPolicy; - - /** - * With the DeletionPolicy attribute you can preserve or (in some cases) backup a resource when its stack is deleted. - * You specify a DeletionPolicy attribute for each resource that you want to control. If a resource has no DeletionPolicy - * attribute, AWS CloudFormation deletes the resource by default. Note that this capability also applies to update operations - * that lead to resources being removed. - */ - deletionPolicy?: DeletionPolicy; - - /** - * Use the UpdatePolicy attribute to specify how AWS CloudFormation handles updates to the AWS::AutoScaling::AutoScalingGroup - * resource. AWS CloudFormation invokes one of three update policies depending on the type of change you make or whether a - * scheduled action is associated with the Auto Scaling group. - */ - updatePolicy?: UpdatePolicy; - - /** - * Metadata associated with the CloudFormation resource. This is not the same as the construct metadata which can be added - * using construct.addMetadata(), but would not appear in the CloudFormation template automatically. - */ - metadata?: { [key: string]: any }; + /** + * A condition to associate with this resource. This means that only if the condition evaluates to 'true' when the stack + * is deployed, the resource will be included. This is provided to allow CDK projects to produce legacy templates, but noramlly + * there is no need to use it in CDK projects. + */ + condition?: Condition; + + /** + * Associate the CreationPolicy attribute with a resource to prevent its status from reaching create complete until + * AWS CloudFormation receives a specified number of success signals or the timeout period is exceeded. To signal a + * resource, you can use the cfn-signal helper script or SignalResource API. AWS CloudFormation publishes valid signals + * to the stack events so that you track the number of signals sent. + */ + creationPolicy?: CreationPolicy; + + /** + * With the DeletionPolicy attribute you can preserve or (in some cases) backup a resource when its stack is deleted. + * You specify a DeletionPolicy attribute for each resource that you want to control. If a resource has no DeletionPolicy + * attribute, AWS CloudFormation deletes the resource by default. Note that this capability also applies to update operations + * that lead to resources being removed. + */ + deletionPolicy?: DeletionPolicy; + + /** + * Use the UpdatePolicy attribute to specify how AWS CloudFormation handles updates to the AWS::AutoScaling::AutoScalingGroup + * resource. AWS CloudFormation invokes one of three update policies depending on the type of change you make or whether a + * scheduled action is associated with the Auto Scaling group. + */ + updatePolicy?: UpdatePolicy; + + /** + * Metadata associated with the CloudFormation resource. This is not the same as the construct metadata which can be added + * using construct.addMetadata(), but would not appear in the CloudFormation template automatically. + */ + metadata?: { [key: string]: any }; } /** @@ -279,34 +279,34 @@ export interface ResourceOptions { * `null`s will cause a value to be deleted. */ export function deepMerge(target: any, source: any) { - if (typeof(source) !== 'object' || typeof(target) !== 'object') { - throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); - } - - for (const key of Object.keys(source)) { - const value = source[key]; - if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { - // if the value at the target is not an object, override it with an - // object so we can continue the recursion - if (typeof(target[key]) !== 'object') { - target[key] = { }; - } - - deepMerge(target[key], value); - - // if the result of the merge is an empty object, it's because the - // eventual value we assigned is `undefined`, and there are no - // sibling concrete values alongside, so we can delete this tree. - const output = target[key]; - if (typeof(output) === 'object' && Object.keys(output).length === 0) { - delete target[key]; - } - } else if (value === undefined) { - delete target[key]; - } else { - target[key] = value; - } + if (typeof(source) !== 'object' || typeof(target) !== 'object') { + throw new Error(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); + } + + for (const key of Object.keys(source)) { + const value = source[key]; + if (typeof(value) === 'object' && value != null && !Array.isArray(value)) { + // if the value at the target is not an object, override it with an + // object so we can continue the recursion + if (typeof(target[key]) !== 'object') { + target[key] = { }; + } + + deepMerge(target[key], value); + + // if the result of the merge is an empty object, it's because the + // eventual value we assigned is `undefined`, and there are no + // sibling concrete values alongside, so we can delete this tree. + const output = target[key]; + if (typeof(output) === 'object' && Object.keys(output).length === 0) { + delete target[key]; + } + } else if (value === undefined) { + delete target[key]; + } else { + target[key] = value; } + } - return target; + return target; } diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index fc79e14ceda29..7ffd6cc936be5 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -1,550 +1,550 @@ import { Test } from 'nodeunit'; import { applyRemovalPolicy, Condition, Construct, DeletionPolicy, - FnEquals, FnNot, HashedAddressingScheme, IDependable, PolicyStatement, - RemovalPolicy, resolve, Resource, Root, Stack } from '../../lib'; + FnEquals, FnNot, HashedAddressingScheme, IDependable, PolicyStatement, + RemovalPolicy, resolve, Resource, Root, Stack } from '../../lib'; export = { - 'all resources derive from Resource, which derives from Entity'(test: Test) { - const stack = new Stack(); - - new Resource(stack, 'MyResource', { - type: 'MyResourceType', - properties: { - Prop1: 'p1', Prop2: 123 - } - }); - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - MyResource: { - Type: "MyResourceType", - Properties: { - Prop1: "p1", - Prop2: 123 - } - } - } - }); - - test.done(); - }, - - 'resources must reside within a Stack and fail upon creation if not'(test: Test) { - const root = new Root(); - test.throws(() => new Resource(root, 'R1', { type: 'ResourceType' })); - test.done(); - }, + 'all resources derive from Resource, which derives from Entity'(test: Test) { + const stack = new Stack(); + + new Resource(stack, 'MyResource', { + type: 'MyResourceType', + properties: { + Prop1: 'p1', Prop2: 123 + } + }); + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + MyResource: { + Type: "MyResourceType", + Properties: { + Prop1: "p1", + Prop2: 123 + } + } + } + }); + + test.done(); + }, + + 'resources must reside within a Stack and fail upon creation if not'(test: Test) { + const root = new Root(); + test.throws(() => new Resource(root, 'R1', { type: 'ResourceType' })); + test.done(); + }, + + 'all entities have a logical ID calculated based on their full path in the tree'(test: Test) { + const stack = new Stack(undefined, 'TestStack', { namingScheme: new HashedAddressingScheme() }); + const level1 = new Construct(stack, 'level1'); + const level2 = new Construct(level1, 'level2'); + const level3 = new Construct(level2, 'level3'); + const res1 = new Resource(level1, 'childoflevel1', { type: 'MyResourceType1' }); + const res2 = new Resource(level3, 'childoflevel3', { type: 'MyResourceType2' }); + + test.equal(withoutHash(res1.logicalId), 'level1childoflevel1'); + test.equal(withoutHash(res2.logicalId), 'level1level2level3childoflevel3'); + + test.done(); + }, + + 'resource.props can only be accessed by derived classes'(test: Test) { + const stack = new Stack(); + const res = new Counter(stack, 'MyResource', { Count: 10 }); + res.increment(); + res.increment(2); + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + MyResource: { Type: 'My::Counter', Properties: { Count: 13 } } + } + }); + + test.done(); + }, + + 'resource attributes can be retrieved using getAtt(s) or attribute properties'(test: Test) { + const stack = new Stack(); + const res = new Counter(stack, 'MyResource', { Count: 10 }); + + new Resource(stack, 'YourResource', { + type: 'Type', + properties: { + CounterName: res.getAtt('Name'), + CounterArn: res.arn, + CounterURL: res.url, + } + }); + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + MyResource: { Type: 'My::Counter', Properties: { Count: 10 } }, + YourResource: { + Type: 'Type', + Properties: { + CounterName: { 'Fn::GetAtt': [ 'MyResource', 'Name' ] }, + CounterArn: { 'Fn::GetAtt': [ 'MyResource', 'Arn' ] } , + CounterURL: { 'Fn::GetAtt': [ 'MyResource', 'URL' ] } + } + } + } + }); + + test.done(); + }, + + 'ARN-type resource attributes have some common functionality'(test: Test) { + const stack = new Stack(); + const res = new Counter(stack, 'MyResource', { Count: 1 }); + new Resource(stack, 'MyResource2', { + type: 'Type', + properties: { + Perm: new PolicyStatement().addResource(res.arn).addActions('counter:add', 'counter:remove') + } + }); + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + MyResource: { Type: "My::Counter", Properties: { Count: 1 } }, + MyResource2: { + Type: "Type", + Properties: { + Perm: { + Effect: "Allow", + Action: [ "counter:add", "counter:remove" ], + Resource: { + "Fn::GetAtt": [ "MyResource", "Arn" ] + } + } + } + } + } + }); + + test.done(); + }, + + 'resource.addDependency(e) can be used to add a DependsOn on another resource'(test: Test) { + const stack = new Stack(); + const r1 = new Counter(stack, 'Counter1', { Count: 1 }); + const r2 = new Counter(stack, 'Counter2', { Count: 1 }); + const r3 = new Resource(stack, 'Resource3', { type: 'MyResourceType' }); + r2.addDependency(r1); + r2.addDependency(r3); + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + Counter1: { + Type: "My::Counter", + Properties: { Count: 1 } + }, + Counter2: { + Type: "My::Counter", + Properties: { Count: 1 }, + DependsOn: [ + "Counter1", + "Resource3" + ] + }, + Resource3: { Type: "MyResourceType" } + } + }); + + test.done(); + }, + + 'conditions can be attached to a resource'(test: Test) { + const stack = new Stack(); + const r1 = new Resource(stack, 'Resource', { type: 'Type' }); + const cond = new Condition(stack, 'MyCondition', { expression: new FnNot(new FnEquals('a', 'b')) }); + r1.options.condition = cond; + + test.deepEqual(stack.toCloudFormation(), { + Resources: { Resource: { Type: 'Type', Condition: 'MyCondition' } }, + Conditions: { MyCondition: { 'Fn::Not': [ { 'Fn::Equals': [ 'a', 'b' ] } ] } } + }); + + test.done(); + }, + + 'creation/update/deletion policies can be set on a resource'(test: Test) { + const stack = new Stack(); + const r1 = new Resource(stack, 'Resource', { type: 'Type' }); + + r1.options.creationPolicy = { autoScalingCreationPolicy: { minSuccessfulInstancesPercent: 10 } }; + // tslint:disable-next-line:max-line-length + r1.options.updatePolicy = { autoScalingScheduledAction: { ignoreUnmodifiedGroupSizeProperties: false }, autoScalingReplacingUpdate: { willReplace: true } }; + r1.options.deletionPolicy = DeletionPolicy.Retain; + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + Resource: { + Type: 'Type', + CreationPolicy: { AutoScalingCreationPolicy: { MinSuccessfulInstancesPercent: 10 } }, + UpdatePolicy: { + AutoScalingScheduledAction: { IgnoreUnmodifiedGroupSizeProperties: false }, + AutoScalingReplacingUpdate: { WillReplace: true } + }, + DeletionPolicy: 'Retain' + } + } + }); + + test.done(); + }, + + 'metadata can be set on a resource'(test: Test) { + const stack = new Stack(); + const r1 = new Resource(stack, 'Resource', { type: 'Type' }); + + r1.options.metadata = { + MyKey: 10, + MyValue: 99 + }; + + test.deepEqual(stack.toCloudFormation(), { + Resources: { + Resource: { + Type: "Type", + Metadata: { + MyKey: 10, + MyValue: 99 + } + } + } + }); + + test.done(); + }, + + 'the "type" property is required when creating a resource'(test: Test) { + const stack = new Stack(); + test.throws(() => new Resource(stack, 'Resource', { notypehere: true } as any)); + test.done(); + }, + + 'the "name" property is deleted when synthesizing into a CloudFormation resource'(test: Test) { + const stack = new Stack(); + + new Resource(stack, 'Bla', { + type: 'MyResource', + properties: { + Prop1: 'value1', + name: 'Bla' + } + }); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { Bla: { Type: 'MyResource', Properties: { Prop1: 'value1' } } } }); + test.done(); + }, + + 'removal policy is a high level abstraction of deletion policy used by l2'(test: Test) { + const stack = new Stack(); + + const orphan = new Resource(stack, 'Orphan', { type: 'T1' }); + const forbid = new Resource(stack, 'Forbid', { type: 'T2' }); + const destroy = new Resource(stack, 'Destroy', { type: 'T3' }); + + applyRemovalPolicy(orphan, RemovalPolicy.Orphan); + applyRemovalPolicy(forbid, RemovalPolicy.Forbid); + applyRemovalPolicy(destroy, RemovalPolicy.Destroy); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { Orphan: { Type: 'T1', DeletionPolicy: 'Retain' }, + Forbid: { Type: 'T2', DeletionPolicy: 'Retain' }, + Destroy: { Type: 'T3' } } }); + test.done(); + }, + + 'addDependency adds all dependencyElements of dependent constructs'(test: Test) { + + class C1 extends Construct implements IDependable { + public readonly r1: Resource; + public readonly r2: Resource; + public readonly r3: Resource; + + constructor(parent: Construct, name: string) { + super(parent, name); + + this.r1 = new Resource(this, 'R1', { type: 'T1' }); + this.r2 = new Resource(this, 'R2', { type: 'T2' }); + this.r3 = new Resource(this, 'R3', { type: 'T3' }); + } + + get dependencyElements() { + return [ this.r1, this.r2 ]; + } + } - 'all entities have a logical ID calculated based on their full path in the tree'(test: Test) { - const stack = new Stack(undefined, 'TestStack', { namingScheme: new HashedAddressingScheme() }); - const level1 = new Construct(stack, 'level1'); - const level2 = new Construct(level1, 'level2'); - const level3 = new Construct(level2, 'level3'); - const res1 = new Resource(level1, 'childoflevel1', { type: 'MyResourceType1' }); - const res2 = new Resource(level3, 'childoflevel3', { type: 'MyResourceType2' }); + class C2 extends Construct implements IDependable { + public readonly r1: Resource; + public readonly r2: Resource; + public readonly r3: Resource; - test.equal(withoutHash(res1.logicalId), 'level1childoflevel1'); - test.equal(withoutHash(res2.logicalId), 'level1level2level3childoflevel3'); + constructor(parent: Construct, name: string) { + super(parent, name); - test.done(); - }, + this.r1 = new Resource(this, 'R1', { type: 'T1' }); + this.r2 = new Resource(this, 'R2', { type: 'T2' }); + this.r3 = new Resource(this, 'R3', { type: 'T3' }); + } - 'resource.props can only be accessed by derived classes'(test: Test) { - const stack = new Stack(); - const res = new Counter(stack, 'MyResource', { Count: 10 }); - res.increment(); - res.increment(2); + get dependencyElements() { + return [ this.r3 ]; + } + } - test.deepEqual(stack.toCloudFormation(), { - Resources: { - MyResource: { Type: 'My::Counter', Properties: { Count: 13 } } - } - }); + // C3 returns [ c2 ] for it's dependency elements + // this should result in 'flattening' the list of elements. + class C3 extends Construct implements IDependable { + private readonly c2: C2; - test.done(); - }, + constructor(parent: Construct, name: string) { + super(parent, name); - 'resource attributes can be retrieved using getAtt(s) or attribute properties'(test: Test) { - const stack = new Stack(); - const res = new Counter(stack, 'MyResource', { Count: 10 }); + this.c2 = new C2(this, 'C2'); + } - new Resource(stack, 'YourResource', { - type: 'Type', - properties: { - CounterName: res.getAtt('Name'), - CounterArn: res.arn, - CounterURL: res.url, - } - }); - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - MyResource: { Type: 'My::Counter', Properties: { Count: 10 } }, - YourResource: { - Type: 'Type', - Properties: { - CounterName: { 'Fn::GetAtt': [ 'MyResource', 'Name' ] }, - CounterArn: { 'Fn::GetAtt': [ 'MyResource', 'Arn' ] } , - CounterURL: { 'Fn::GetAtt': [ 'MyResource', 'URL' ] } - } - } - } - }); + get dependencyElements() { + return [ this.c2 ]; + } + } - test.done(); + const stack = new Stack(); + const c1 = new C1(stack, 'MyC1'); + const c2 = new C2(stack, 'MyC2'); + const c3 = new C3(stack, 'MyC3'); + + const dependingResource = new Resource(stack, 'MyResource', { type: 'R' }); + dependingResource.addDependency(c1, c2); + dependingResource.addDependency(c3); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyC1R1FB2A562F: { Type: 'T1' }, + MyC1R2AE2B5066: { Type: 'T2' }, + MyC1R374967D02: { Type: 'T3' }, + MyC2R13C9A618D: { Type: 'T1' }, + MyC2R25330F905: { Type: 'T2' }, + MyC2R3809EEAD6: { Type: 'T3' }, + MyC3C2R1C64551A7: { Type: 'T1' }, + MyC3C2R2F213BD26: { Type: 'T2' }, + MyC3C2R38CE6F9F7: { Type: 'T3' }, + MyResource: + { Type: 'R', + DependsOn: + [ 'MyC1R1FB2A562F', + 'MyC1R2AE2B5066', + 'MyC2R3809EEAD6', + 'MyC3C2R38CE6F9F7' ] } } }); + test.done(); + }, + + 'resource.ref returns the {Ref} token'(test: Test) { + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { type: 'R' }); + + test.deepEqual(resolve(r.ref), { Ref: 'MyResource' }); + test.done(); + }, + + 'overrides': { + 'addOverride(p, v) allows assigning arbitrary values to synthesized resource definitions'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); + + // WHEN + r.addOverride('Type', 'YouCanEvenOverrideTheType'); + r.addOverride('Metadata', { Key: 12 }); + r.addOverride('Use.Dot.Notation', 'To create subtrees'); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'YouCanEvenOverrideTheType', + Use: { Dot: { Notation: 'To create subtrees' } }, + Metadata: { Key: 12 } } } }); + + test.done(); }, - 'ARN-type resource attributes have some common functionality'(test: Test) { - const stack = new Stack(); - const res = new Counter(stack, 'MyResource', { Count: 1 }); - new Resource(stack, 'MyResource2', { - type: 'Type', - properties: { - Perm: new PolicyStatement().addResource(res.arn).addActions('counter:add', 'counter:remove') - } - }); - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - MyResource: { Type: "My::Counter", Properties: { Count: 1 } }, - MyResource2: { - Type: "Type", - Properties: { - Perm: { - Effect: "Allow", - Action: [ "counter:add", "counter:remove" ], - Resource: { - "Fn::GetAtt": [ "MyResource", "Arn" ] - } - } - } + 'addOverride(p, null) will assign an "null" value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, } } - }); - - test.done(); - }, - - 'resource.addDependency(e) can be used to add a DependsOn on another resource'(test: Test) { - const stack = new Stack(); - const r1 = new Counter(stack, 'Counter1', { Count: 1 }); - const r2 = new Counter(stack, 'Counter2', { Count: 1 }); - const r3 = new Resource(stack, 'Resource3', { type: 'MyResourceType' }); - r2.addDependency(r1); - r2.addDependency(r3); - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - Counter1: { - Type: "My::Counter", - Properties: { Count: 1 } - }, - Counter2: { - Type: "My::Counter", - Properties: { Count: 1 }, - DependsOn: [ - "Counter1", - "Resource3" - ] - }, - Resource3: { Type: "MyResourceType" } - } - }); - - test.done(); - }, + } + }); - 'conditions can be attached to a resource'(test: Test) { - const stack = new Stack(); - const r1 = new Resource(stack, 'Resource', { type: 'Type' }); - const cond = new Condition(stack, 'MyCondition', { expression: new FnNot(new FnEquals('a', 'b')) }); - r1.options.condition = cond; + // WHEN + r.addOverride('Properties.Hello.World.Value2', null); - test.deepEqual(stack.toCloudFormation(), { - Resources: { Resource: { Type: 'Type', Condition: 'MyCondition' } }, - Conditions: { MyCondition: { 'Fn::Not': [ { 'Fn::Equals': [ 'a', 'b' ] } ] } } - }); + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); - test.done(); + test.done(); }, - 'creation/update/deletion policies can be set on a resource'(test: Test) { - const stack = new Stack(); - const r1 = new Resource(stack, 'Resource', { type: 'Type' }); - - r1.options.creationPolicy = { autoScalingCreationPolicy: { minSuccessfulInstancesPercent: 10 } }; - // tslint:disable-next-line:max-line-length - r1.options.updatePolicy = { autoScalingScheduledAction: { ignoreUnmodifiedGroupSizeProperties: false }, autoScalingReplacingUpdate: { willReplace: true } }; - r1.options.deletionPolicy = DeletionPolicy.Retain; - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - Resource: { - Type: 'Type', - CreationPolicy: { AutoScalingCreationPolicy: { MinSuccessfulInstancesPercent: 10 } }, - UpdatePolicy: { - AutoScalingScheduledAction: { IgnoreUnmodifiedGroupSizeProperties: false }, - AutoScalingReplacingUpdate: { WillReplace: true } - }, - DeletionPolicy: 'Retain' - } + 'addOverride(p, undefined) can be used to delete a value'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, } - }); - - test.done(); - }, - - 'metadata can be set on a resource'(test: Test) { - const stack = new Stack(); - const r1 = new Resource(stack, 'Resource', { type: 'Type' }); + } + } + }); - r1.options.metadata = { - MyKey: 10, - MyValue: 99 - }; - - test.deepEqual(stack.toCloudFormation(), { - Resources: { - Resource: { - Type: "Type", - Metadata: { - MyKey: 10, - MyValue: 99 - } - } - } - }); + // WHEN + r.addOverride('Properties.Hello.World.Value2', undefined); - test.done(); - }, + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); - 'the "type" property is required when creating a resource'(test: Test) { - const stack = new Stack(); - test.throws(() => new Resource(stack, 'Resource', { notypehere: true } as any)); - test.done(); + test.done(); }, - 'the "name" property is deleted when synthesizing into a CloudFormation resource'(test: Test) { - const stack = new Stack(); + 'addOverride(p, undefined) will not create empty trees'(test: Test) { + // GIVEN + const stack = new Stack(); - new Resource(stack, 'Bla', { - type: 'MyResource', - properties: { - Prop1: 'value1', - name: 'Bla' - } - }); - - test.deepEqual(stack.toCloudFormation(), { Resources: - { Bla: { Type: 'MyResource', Properties: { Prop1: 'value1' } } } }); - test.done(); - }, + const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); - 'removal policy is a high level abstraction of deletion policy used by l2'(test: Test) { - const stack = new Stack(); + // WHEN + r.addPropertyOverride('Tree.Exists', 42); + r.addPropertyOverride('Tree.Does.Not.Exist', undefined); - const orphan = new Resource(stack, 'Orphan', { type: 'T1' }); - const forbid = new Resource(stack, 'Forbid', { type: 'T2' }); - const destroy = new Resource(stack, 'Destroy', { type: 'T3' }); + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Tree: { Exists: 42 } } } } }); - applyRemovalPolicy(orphan, RemovalPolicy.Orphan); - applyRemovalPolicy(forbid, RemovalPolicy.Forbid); - applyRemovalPolicy(destroy, RemovalPolicy.Destroy); - - test.deepEqual(stack.toCloudFormation(), { Resources: - { Orphan: { Type: 'T1', DeletionPolicy: 'Retain' }, - Forbid: { Type: 'T2', DeletionPolicy: 'Retain' }, - Destroy: { Type: 'T3' } } }); - test.done(); + test.done(); }, - 'addDependency adds all dependencyElements of dependent constructs'(test: Test) { - - class C1 extends Construct implements IDependable { - public readonly r1: Resource; - public readonly r2: Resource; - public readonly r3: Resource; - - constructor(parent: Construct, name: string) { - super(parent, name); - - this.r1 = new Resource(this, 'R1', { type: 'T1' }); - this.r2 = new Resource(this, 'R2', { type: 'T2' }); - this.r3 = new Resource(this, 'R3', { type: 'T3' }); - } - - get dependencyElements() { - return [ this.r1, this.r2 ]; - } - } - - class C2 extends Construct implements IDependable { - public readonly r1: Resource; - public readonly r2: Resource; - public readonly r3: Resource; - - constructor(parent: Construct, name: string) { - super(parent, name); - - this.r1 = new Resource(this, 'R1', { type: 'T1' }); - this.r2 = new Resource(this, 'R2', { type: 'T2' }); - this.r3 = new Resource(this, 'R3', { type: 'T3' }); - } - - get dependencyElements() { - return [ this.r3 ]; + 'addDeletionOverride(p) and addPropertyDeletionOverride(pp) are sugar `undefined`'(test: Test) { + // GIVEN + const stack = new Stack(); + + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: { + Value1: 'Hello', + Value2: 129, + Value3: [ 'foo', 'bar' ] } + } } + }); - // C3 returns [ c2 ] for it's dependency elements - // this should result in 'flattening' the list of elements. - class C3 extends Construct implements IDependable { - private readonly c2: C2; + // WHEN + r.addDeletionOverride('Properties.Hello.World.Value2'); + r.addPropertyDeletionOverride('Hello.World.Value3'); - constructor(parent: Construct, name: string) { - super(parent, name); + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); - this.c2 = new C2(this, 'C2'); - } - - get dependencyElements() { - return [ this.c2 ]; - } - } - - const stack = new Stack(); - const c1 = new C1(stack, 'MyC1'); - const c2 = new C2(stack, 'MyC2'); - const c3 = new C3(stack, 'MyC3'); - - const dependingResource = new Resource(stack, 'MyResource', { type: 'R' }); - dependingResource.addDependency(c1, c2); - dependingResource.addDependency(c3); - - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyC1R1FB2A562F: { Type: 'T1' }, - MyC1R2AE2B5066: { Type: 'T2' }, - MyC1R374967D02: { Type: 'T3' }, - MyC2R13C9A618D: { Type: 'T1' }, - MyC2R25330F905: { Type: 'T2' }, - MyC2R3809EEAD6: { Type: 'T3' }, - MyC3C2R1C64551A7: { Type: 'T1' }, - MyC3C2R2F213BD26: { Type: 'T2' }, - MyC3C2R38CE6F9F7: { Type: 'T3' }, - MyResource: - { Type: 'R', - DependsOn: - [ 'MyC1R1FB2A562F', - 'MyC1R2AE2B5066', - 'MyC2R3809EEAD6', - 'MyC3C2R38CE6F9F7' ] } } }); - test.done(); + test.done(); }, - 'resource.ref returns the {Ref} token'(test: Test) { - const stack = new Stack(); - const r = new Resource(stack, 'MyResource', { type: 'R' }); - - test.deepEqual(resolve(r.ref), { Ref: 'MyResource' }); - test.done(); + 'addOverride(p, v) will overwrite any non-objects along the path'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { + Hello: { + World: 42 + } + } + }); + + // WHEN + r.addOverride('Properties.Override1', [ 'Hello', 123 ]); + r.addOverride('Properties.Override1.Override2', { Heyy: [ 1 ] }); + r.addOverride('Properties.Hello.World.Foo.Bar', 42); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: + { Hello: { World: { Foo: { Bar: 42 } } }, + Override1: { + Override2: { Heyy: [ 1] } + } } } } }); + test.done(); }, - 'overrides': { - 'addOverride(p, v) allows assigning arbitrary values to synthesized resource definitions'(test: Test) { - // GIVEN - const stack = new Stack(); - const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); - - // WHEN - r.addOverride('Type', 'YouCanEvenOverrideTheType'); - r.addOverride('Metadata', { Key: 12 }); - r.addOverride('Use.Dot.Notation', 'To create subtrees'); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'YouCanEvenOverrideTheType', - Use: { Dot: { Notation: 'To create subtrees' } }, - Metadata: { Key: 12 } } } }); - - test.done(); - }, - - 'addOverride(p, null) will assign an "null" value'(test: Test) { - // GIVEN - const stack = new Stack(); - - const r = new Resource(stack, 'MyResource', { - type: 'AWS::Resource::Type', - properties: { - Hello: { - World: { - Value1: 'Hello', - Value2: 129, - } - } - } - }); - - // WHEN - r.addOverride('Properties.Hello.World.Value2', null); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello', Value2: null } } } } } }); - - test.done(); - }, - - 'addOverride(p, undefined) can be used to delete a value'(test: Test) { - // GIVEN - const stack = new Stack(); - - const r = new Resource(stack, 'MyResource', { - type: 'AWS::Resource::Type', - properties: { - Hello: { - World: { - Value1: 'Hello', - Value2: 129, - } - } - } - }); - - // WHEN - r.addOverride('Properties.Hello.World.Value2', undefined); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); - - test.done(); - }, - - 'addOverride(p, undefined) will not create empty trees'(test: Test) { - // GIVEN - const stack = new Stack(); - - const r = new Resource(stack, 'MyResource', { type: 'AWS::Resource::Type' }); - - // WHEN - r.addPropertyOverride('Tree.Exists', 42); - r.addPropertyOverride('Tree.Does.Not.Exist', undefined); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Tree: { Exists: 42 } } } } }); - - test.done(); - }, - - 'addDeletionOverride(p) and addPropertyDeletionOverride(pp) are sugar `undefined`'(test: Test) { - // GIVEN - const stack = new Stack(); - - const r = new Resource(stack, 'MyResource', { - type: 'AWS::Resource::Type', - properties: { - Hello: { - World: { - Value1: 'Hello', - Value2: 129, - Value3: [ 'foo', 'bar' ] - } - } - } - }); - - // WHEN - r.addDeletionOverride('Properties.Hello.World.Value2'); - r.addPropertyDeletionOverride('Hello.World.Value3'); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Value1: 'Hello' } } } } } }); - - test.done(); - }, - - 'addOverride(p, v) will overwrite any non-objects along the path'(test: Test) { - // GIVEN - const stack = new Stack(); - const r = new Resource(stack, 'MyResource', { - type: 'AWS::Resource::Type', - properties: { - Hello: { - World: 42 - } - } - }); - - // WHEN - r.addOverride('Properties.Override1', [ 'Hello', 123 ]); - r.addOverride('Properties.Override1.Override2', { Heyy: [ 1 ] }); - r.addOverride('Properties.Hello.World.Foo.Bar', 42); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: - { Hello: { World: { Foo: { Bar: 42 } } }, - Override1: { - Override2: { Heyy: [ 1] } - } } } } }); - test.done(); - }, - - 'addPropertyOverride(pp, v) is a sugar for overriding properties'(test: Test) { - // GIVEN - const stack = new Stack(); - const r = new Resource(stack, 'MyResource', { - type: 'AWS::Resource::Type', - properties: { Hello: { World: 42 } } - }); - - // WHEN - r.addPropertyOverride('Hello.World', { Hey: 'Jude' }); - - // THEN - test.deepEqual(stack.toCloudFormation(), { Resources: - { MyResource: - { Type: 'AWS::Resource::Type', - Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); - test.done(); - } + 'addPropertyOverride(pp, v) is a sugar for overriding properties'(test: Test) { + // GIVEN + const stack = new Stack(); + const r = new Resource(stack, 'MyResource', { + type: 'AWS::Resource::Type', + properties: { Hello: { World: 42 } } + }); + + // WHEN + r.addPropertyOverride('Hello.World', { Hey: 'Jude' }); + + // THEN + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'AWS::Resource::Type', + Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); + test.done(); } + } }; interface CounterProps { - // tslint:disable-next-line:variable-name - Count: number; + // tslint:disable-next-line:variable-name + Count: number; } class Counter extends Resource { - public readonly arn: string; - public readonly url: string; - - constructor(parent: Construct, name: string, props: CounterProps) { - super(parent, name, { type: 'My::Counter', properties: { Count: props.Count } }); - this.arn = this.getAtt('Arn').toString(); - this.url = this.getAtt('URL').toString(); - } - - public increment(by = 1) { - this.properties.Count += by; - } + public readonly arn: string; + public readonly url: string; + + constructor(parent: Construct, name: string, props: CounterProps) { + super(parent, name, { type: 'My::Counter', properties: { Count: props.Count } }); + this.arn = this.getAtt('Arn').toString(); + this.url = this.getAtt('URL').toString(); + } + + public increment(by = 1) { + this.properties.Count += by; + } } function withoutHash(logId: string) { - return logId.substr(0, logId.length - 8); + return logId.substr(0, logId.length - 8); } diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 1cbc62b20363e..ffd18a56ef6ec 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -15,622 +15,622 @@ interface Dictionary { [key: string]: T; } * Emits classes for all resource types */ export default class CodeGenerator { - public readonly outputFile: string; - - private code = new CodeMaker(); - - /** - * Creates the code generator. - * @param moduleName the name of the module (used to determine the file name). - * @param spec CloudFormation resource specification - */ - constructor(moduleName: string, private readonly spec: schema.Specification) { - this.outputFile = `${moduleName}.generated.ts`; - this.code.openFile(this.outputFile); - - const meta = { - generated: new Date(), - fingerprint: spec.Fingerprint - }; - - this.code.line('// Copyright 2012-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.'); - this.code.line('// Generated from the AWS CloudFormation Resource Specification'); - this.code.line('// See: docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html'); - this.code.line(`// @cfn2ts:meta@ ${JSON.stringify(meta)}`); - this.code.line(); - this.code.line('// tslint:disable:max-line-length | This is generated code - line lengths are difficult to control'); - this.code.line(); - this.code.line(`import ${CORE} = require('@aws-cdk/cdk');`); + public readonly outputFile: string; + + private code = new CodeMaker(); + + /** + * Creates the code generator. + * @param moduleName the name of the module (used to determine the file name). + * @param spec CloudFormation resource specification + */ + constructor(moduleName: string, private readonly spec: schema.Specification) { + this.outputFile = `${moduleName}.generated.ts`; + this.code.openFile(this.outputFile); + + const meta = { + generated: new Date(), + fingerprint: spec.Fingerprint + }; + + this.code.line('// Copyright 2012-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.'); + this.code.line('// Generated from the AWS CloudFormation Resource Specification'); + this.code.line('// See: docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html'); + this.code.line(`// @cfn2ts:meta@ ${JSON.stringify(meta)}`); + this.code.line(); + this.code.line('// tslint:disable:max-line-length | This is generated code - line lengths are difficult to control'); + this.code.line(); + this.code.line(`import ${CORE} = require('@aws-cdk/cdk');`); + } + + public async upToDate(outPath: string): Promise { + const fullPath = path.join(outPath, this.outputFile); + if (!await fs.pathExists(fullPath)) { + return false; } - - public async upToDate(outPath: string): Promise { - const fullPath = path.join(outPath, this.outputFile); - if (!await fs.pathExists(fullPath)) { - return false; - } - const data = await fs.readFile(fullPath, { encoding: 'utf-8' }); - const comment = data.match(/^\s*[/]{2}\s*@cfn2ts:meta@(.+)$/m); - if (comment) { - try { - const meta = JSON.parse(comment[1]); - if (meta.fingerprint === this.spec.Fingerprint) { - return true; - } - } catch { - return false; - } + const data = await fs.readFile(fullPath, { encoding: 'utf-8' }); + const comment = data.match(/^\s*[/]{2}\s*@cfn2ts:meta@(.+)$/m); + if (comment) { + try { + const meta = JSON.parse(comment[1]); + if (meta.fingerprint === this.spec.Fingerprint) { + return true; } + } catch { return false; + } } + return false; + } - public emitCode() { - for (const name of Object.keys(this.spec.ResourceTypes).sort()) { - const resourceType = this.spec.ResourceTypes[name]; + public emitCode() { + for (const name of Object.keys(this.spec.ResourceTypes).sort()) { + const resourceType = this.spec.ResourceTypes[name]; - this.validateRefKindPresence(name, resourceType); + this.validateRefKindPresence(name, resourceType); - const cfnName = SpecName.parse(name); - const resourceName = genspec.CodeName.forResource(cfnName); - this.code.line(); - this.code.openBlock('export namespace cloudformation'); - const attributeTypes = this.emitResourceType(resourceName, resourceType); + const cfnName = SpecName.parse(name); + const resourceName = genspec.CodeName.forResource(cfnName); + this.code.line(); + this.code.openBlock('export namespace cloudformation'); + const attributeTypes = this.emitResourceType(resourceName, resourceType); - this.emitPropertyTypes(name); + this.emitPropertyTypes(name); - this.code.closeBlock(); + this.code.closeBlock(); - for (const attributeType of attributeTypes) { - this.emitAttributeType(attributeType); - } - } + for (const attributeType of attributeTypes) { + this.emitAttributeType(attributeType); + } } - - /** - * Saves the generated file. - */ - public async save(dir: string) { - this.code.closeFile(this.outputFile); - return await this.code.save(dir); + } + + /** + * Saves the generated file. + */ + public async save(dir: string) { + this.code.closeFile(this.outputFile); + return await this.code.save(dir); + } + + /** + * Emits classes for all property types + */ + private emitPropertyTypes(resourceName: string) { + const prefix = `${resourceName}.`; + for (const name of Object.keys(this.spec.PropertyTypes).sort()) { + if (!name.startsWith(prefix)) { continue; } + const cfnName = PropertyAttributeName.parse(name); + const propTypeName = genspec.CodeName.forPropertyType(cfnName); + this.emitPropertyType(propTypeName, this.spec.PropertyTypes[name]); } - - /** - * Emits classes for all property types - */ - private emitPropertyTypes(resourceName: string) { - const prefix = `${resourceName}.`; - for (const name of Object.keys(this.spec.PropertyTypes).sort()) { - if (!name.startsWith(prefix)) { continue; } - const cfnName = PropertyAttributeName.parse(name); - const propTypeName = genspec.CodeName.forPropertyType(cfnName); - this.emitPropertyType(propTypeName, this.spec.PropertyTypes[name]); - } + } + + private openClass(name: genspec.CodeName, docLink?: string, superClasses?: string) { + const extendsPostfix = superClasses ? ` extends ${superClasses}` : ''; + this.docLink(docLink); + this.code.openBlock(`export class ${name.className}${extendsPostfix}`); + return name.className; + } + + private closeClass(_name: genspec.CodeName) { + this.code.closeBlock(); + } + + private emitPropsType(resourceName: genspec.CodeName, spec: schema.ResourceType): genspec.CodeName | undefined { + if (!spec.Properties || Object.keys(spec.Properties).length === 0) { return; } + const name = genspec.CodeName.forResourceProperties(resourceName); + + this.docLink(spec.Documentation); + this.code.openBlock(`export interface ${name.className}`); + + const conversionTable = this.emitPropsTypeProperties(resourceName.specName!, spec.Properties); + + this.code.closeBlock(); + + this.code.line(); + this.emitValidator(name, spec.Properties, conversionTable); + this.code.line(); + this.emitCloudFormationMapper(name, spec.Properties, conversionTable); + + return name; + } + + /** + * Emit TypeScript for each of the CloudFormation properties, while renaming + * + * Return a mapping of { originalName -> newName }. + */ + private emitPropsTypeProperties(resourceName: SpecName, propertiesSpec: { [name: string]: schema.Property }): Dictionary { + const propertyMap: Dictionary = {}; + + // Sanity check that our renamed "Name" is not going to conflict with a real property + const renamedNameProperty = resourceNameProperty(resourceName); + const lowerNames = Object.keys(propertiesSpec).map(s => s.toLowerCase()); + if (lowerNames.indexOf('name') !== -1 && lowerNames.indexOf(renamedNameProperty.toLowerCase()) !== -1) { + // tslint:disable-next-line:max-line-length + throw new Error(`Oh gosh, we want to rename ${resourceName.fqn}'s 'Name' property to '${renamedNameProperty}', but that property already exists! We need to find a solution to this problem.`); } - private openClass(name: genspec.CodeName, docLink?: string, superClasses?: string) { - const extendsPostfix = superClasses ? ` extends ${superClasses}` : ''; - this.docLink(docLink); - this.code.openBlock(`export class ${name.className}${extendsPostfix}`); - return name.className; - } + Object.keys(propertiesSpec).sort(propertyComparator).forEach(propName => { + const originalName = propName; + const propSpec = propertiesSpec[propName]; + const additionalDocs = resourceName.relativeName(propName).fqn; - private closeClass(_name: genspec.CodeName) { - this.code.closeBlock(); - } - - private emitPropsType(resourceName: genspec.CodeName, spec: schema.ResourceType): genspec.CodeName | undefined { - if (!spec.Properties || Object.keys(spec.Properties).length === 0) { return; } - const name = genspec.CodeName.forResourceProperties(resourceName); - - this.docLink(spec.Documentation); - this.code.openBlock(`export interface ${name.className}`); + if (propName.toLocaleLowerCase() === 'name') { + propName = renamedNameProperty; + // tslint:disable-next-line:no-console + console.error(`Renamed property 'Name' of ${resourceName.fqn} to '${renamedNameProperty}'`); + } - const conversionTable = this.emitPropsTypeProperties(resourceName.specName!, spec.Properties); - - this.code.closeBlock(); - - this.code.line(); - this.emitValidator(name, spec.Properties, conversionTable); - this.code.line(); - this.emitCloudFormationMapper(name, spec.Properties, conversionTable); - - return name; - } + const resourceCodeName = genspec.CodeName.forResource(resourceName); + const newName = this.emitProperty(resourceCodeName, propName, propSpec, quoteCode(additionalDocs)); + propertyMap[originalName] = newName; + }); + return propertyMap; /** - * Emit TypeScript for each of the CloudFormation properties, while renaming - * - * Return a mapping of { originalName -> newName }. + * A comparator that places required properties before optional properties, + * and sorts properties alphabetically. + * @param l the left property name. + * @param r the right property name. */ - private emitPropsTypeProperties(resourceName: SpecName, propertiesSpec: { [name: string]: schema.Property }): Dictionary { - const propertyMap: Dictionary = {}; - - // Sanity check that our renamed "Name" is not going to conflict with a real property - const renamedNameProperty = resourceNameProperty(resourceName); - const lowerNames = Object.keys(propertiesSpec).map(s => s.toLowerCase()); - if (lowerNames.indexOf('name') !== -1 && lowerNames.indexOf(renamedNameProperty.toLowerCase()) !== -1) { - // tslint:disable-next-line:max-line-length - throw new Error(`Oh gosh, we want to rename ${resourceName.fqn}'s 'Name' property to '${renamedNameProperty}', but that property already exists! We need to find a solution to this problem.`); - } - - Object.keys(propertiesSpec).sort(propertyComparator).forEach(propName => { - const originalName = propName; - const propSpec = propertiesSpec[propName]; - const additionalDocs = resourceName.relativeName(propName).fqn; - - if (propName.toLocaleLowerCase() === 'name') { - propName = renamedNameProperty; - // tslint:disable-next-line:no-console - console.error(`Renamed property 'Name' of ${resourceName.fqn} to '${renamedNameProperty}'`); - } - - const resourceCodeName = genspec.CodeName.forResource(resourceName); - const newName = this.emitProperty(resourceCodeName, propName, propSpec, quoteCode(additionalDocs)); - propertyMap[originalName] = newName; - }); - return propertyMap; - - /** - * A comparator that places required properties before optional properties, - * and sorts properties alphabetically. - * @param l the left property name. - * @param r the right property name. - */ - function propertyComparator(l: string, r: string): number { - const lp = propertiesSpec[l]; - const rp = propertiesSpec[r]; - if (lp.Required === rp.Required) { - return l.localeCompare(r); - } else if (lp.Required) { - return -1; - } - return 1; - } + function propertyComparator(l: string, r: string): number { + const lp = propertiesSpec[l]; + const rp = propertiesSpec[r]; + if (lp.Required === rp.Required) { + return l.localeCompare(r); + } else if (lp.Required) { + return -1; + } + return 1; } + } - private emitResourceType(resourceName: genspec.CodeName, spec: schema.ResourceType) { - this.beginNamespace(resourceName); - - // - // Props Bag for this Resource - // + private emitResourceType(resourceName: genspec.CodeName, spec: schema.ResourceType) { + this.beginNamespace(resourceName); - const propsType = this.emitPropsType(resourceName, spec); - if (propsType) { - this.code.line(); - } - this.openClass(resourceName, spec.Documentation, RESOURCE_BASE_CLASS); - - // - // Static inspectors. - // - - this.code.line('/**'); - this.code.line(` * The CloudFormation resource type name for this resource class.`); - this.code.line(' */'); - this.code.line(`public static readonly resourceTypeName = ${JSON.stringify(resourceName.specName!.fqn)};`); - - if (spec.RequiredTransform) { - this.code.line('/**'); - this.code.line(' * The ``Transform`` a template must use in order to use this resource'); - this.code.line(' */'); - this.code.line(`public static readonly requiredTransform = ${JSON.stringify(spec.RequiredTransform)};`); - } + // + // Props Bag for this Resource + // - // - // Attributes - // + const propsType = this.emitPropsType(resourceName, spec); + if (propsType) { + this.code.line(); + } + this.openClass(resourceName, spec.Documentation, RESOURCE_BASE_CLASS); + + // + // Static inspectors. + // + + this.code.line('/**'); + this.code.line(` * The CloudFormation resource type name for this resource class.`); + this.code.line(' */'); + this.code.line(`public static readonly resourceTypeName = ${JSON.stringify(resourceName.specName!.fqn)};`); + + if (spec.RequiredTransform) { + this.code.line('/**'); + this.code.line(' * The ``Transform`` a template must use in order to use this resource'); + this.code.line(' */'); + this.code.line(`public static readonly requiredTransform = ${JSON.stringify(spec.RequiredTransform)};`); + } - const attributeTypes = new Array(); - const attributes = new Array(); + // + // Attributes + // - if (spec.Attributes) { - for (const attributeName of Object.keys(spec.Attributes).sort()) { - const attributeSpec = spec.Attributes![attributeName]; + const attributeTypes = new Array(); + const attributes = new Array(); - this.code.line(); + if (spec.Attributes) { + for (const attributeName of Object.keys(spec.Attributes).sort()) { + const attributeSpec = spec.Attributes![attributeName]; - this.docLink(undefined, `@cloudformation_attribute ${attributeName}`); - const attr = genspec.attributeDefinition(resourceName, attributeName, attributeSpec); + this.code.line(); - this.code.line(`public readonly ${attr.propertyName}: ${attr.attributeType.typeName.className};`); + this.docLink(undefined, `@cloudformation_attribute ${attributeName}`); + const attr = genspec.attributeDefinition(resourceName, attributeName, attributeSpec); - attributes.push(attr); - attributeTypes.push(attr.attributeType); - } - } + this.code.line(`public readonly ${attr.propertyName}: ${attr.attributeType.typeName.className};`); - // - // Ref attribute - // - if (spec.RefKind !== schema.SpecialRefKind.None) { - const refAttribute = genspec.refAttributeDefinition(resourceName, spec.RefKind!); - - // If there's already an attribute with the same name, ref is not needed - if (!attributes.some(a => a.propertyName === refAttribute.propertyName)) { - this.code.line(`public readonly ${refAttribute.propertyName}: ${refAttribute.attributeType.typeName.className};`); - attributes.push(refAttribute); - attributeTypes.push(refAttribute.attributeType); - } - } + attributes.push(attr); + attributeTypes.push(attr.attributeType); + } + } - // - // Constructor - // - this.code.line(); - this.code.line('/**'); - this.code.line(` * Creates a new ${quoteCode(resourceName.specName!.fqn)}.`); - this.code.line(' *'); - this.code.line(` * @param parent the ${quoteCode(CONSTRUCT_CLASS)} this ${quoteCode(resourceName.className)} is a part of`); - this.code.line(` * @param name the name of the resource in the ${quoteCode(CONSTRUCT_CLASS)} tree`); - this.code.line(` * @param properties the properties of this ${quoteCode(resourceName.className)}`); - this.code.line(' */'); - const optionalProps = spec.Properties && !Object.values(spec.Properties).some(p => p.Required); - const propsArgument = propsType ? `, properties${optionalProps ? '?' : ''}: ${propsType.className}` : ''; - this.code.openBlock(`constructor(parent: ${CONSTRUCT_CLASS}, name: string${propsArgument})`); - this.code.line(`super(parent, name, { type: ${resourceName.className}.resourceTypeName${propsType ? ', properties' : ''} });`); - // verify all required properties - if (spec.Properties) { - for (const pname of Object.keys(spec.Properties)) { - const prop = spec.Properties[pname]; - if (prop.Required) { - const propName = pname.toLocaleLowerCase() === 'name' ? resourceNameProperty(resourceName.specName!) : pname; - this.code.line(`${CORE}.requireProperty(properties, '${genspec.cloudFormationToScriptName(propName)}', this);`); - } - } - } - if (spec.RequiredTransform) { - const transformField = `${resourceName.className}.requiredTransform`; - this.code.line('// If a different transform than the required one is in use, this resource cannot be used'); - this.code.openBlock(`if (this.stack.templateOptions.transform && this.stack.templateOptions.transform !== ${transformField})`); - // tslint:disable-next-line:max-line-length - this.code.line(`throw new Error(\`The \${JSON.stringify(${transformField})} transform is required when using ${resourceName.className}, but the \${JSON.stringify(this.stack.templateOptions.transform)} is used.\`);`); - this.code.closeBlock(); - this.code.line('// Automatically configure the required transform'); - this.code.line(`this.stack.templateOptions.transform = ${resourceName.className}.requiredTransform;`); - } + // + // Ref attribute + // + if (spec.RefKind !== schema.SpecialRefKind.None) { + const refAttribute = genspec.refAttributeDefinition(resourceName, spec.RefKind!); + + // If there's already an attribute with the same name, ref is not needed + if (!attributes.some(a => a.propertyName === refAttribute.propertyName)) { + this.code.line(`public readonly ${refAttribute.propertyName}: ${refAttribute.attributeType.typeName.className};`); + attributes.push(refAttribute); + attributeTypes.push(refAttribute.attributeType); + } + } - // initialize all attribute properties - for (const at of attributes) { - if (at.attributeType.isPrimitive) { - if (at.attributeType.typeName.className === 'string') { - this.code.line(`this.${at.propertyName} = ${at.constructorArguments}.toString();`); - } else { - throw new Error(`Unsupported primitive attribute type ${at.attributeType.typeName.className}`); - } - } else { - this.code.line(`this.${at.propertyName} = new ${at.attributeType.typeName.className}(${at.constructorArguments});`); - } + // + // Constructor + // + this.code.line(); + this.code.line('/**'); + this.code.line(` * Creates a new ${quoteCode(resourceName.specName!.fqn)}.`); + this.code.line(' *'); + this.code.line(` * @param parent the ${quoteCode(CONSTRUCT_CLASS)} this ${quoteCode(resourceName.className)} is a part of`); + this.code.line(` * @param name the name of the resource in the ${quoteCode(CONSTRUCT_CLASS)} tree`); + this.code.line(` * @param properties the properties of this ${quoteCode(resourceName.className)}`); + this.code.line(' */'); + const optionalProps = spec.Properties && !Object.values(spec.Properties).some(p => p.Required); + const propsArgument = propsType ? `, properties${optionalProps ? '?' : ''}: ${propsType.className}` : ''; + this.code.openBlock(`constructor(parent: ${CONSTRUCT_CLASS}, name: string${propsArgument})`); + this.code.line(`super(parent, name, { type: ${resourceName.className}.resourceTypeName${propsType ? ', properties' : ''} });`); + // verify all required properties + if (spec.Properties) { + for (const pname of Object.keys(spec.Properties)) { + const prop = spec.Properties[pname]; + if (prop.Required) { + const propName = pname.toLocaleLowerCase() === 'name' ? resourceNameProperty(resourceName.specName!) : pname; + this.code.line(`${CORE}.requireProperty(properties, '${genspec.cloudFormationToScriptName(propName)}', this);`); } + } + } + if (spec.RequiredTransform) { + const transformField = `${resourceName.className}.requiredTransform`; + this.code.line('// If a different transform than the required one is in use, this resource cannot be used'); + this.code.openBlock(`if (this.stack.templateOptions.transform && this.stack.templateOptions.transform !== ${transformField})`); + // tslint:disable-next-line:max-line-length + this.code.line(`throw new Error(\`The \${JSON.stringify(${transformField})} transform is required when using ${resourceName.className}, but the \${JSON.stringify(this.stack.templateOptions.transform)} is used.\`);`); + this.code.closeBlock(); + this.code.line('// Automatically configure the required transform'); + this.code.line(`this.stack.templateOptions.transform = ${resourceName.className}.requiredTransform;`); + } - this.code.closeBlock(); - - if (propsType) { - this.code.line(); - this.emitCloudFormationPropertiesOverride(propsType); + // initialize all attribute properties + for (const at of attributes) { + if (at.attributeType.isPrimitive) { + if (at.attributeType.typeName.className === 'string') { + this.code.line(`this.${at.propertyName} = ${at.constructorArguments}.toString();`); + } else { + throw new Error(`Unsupported primitive attribute type ${at.attributeType.typeName.className}`); } - - this.closeClass(resourceName); - - this.endNamespace(resourceName); - - return attributeTypes; + } else { + this.code.line(`this.${at.propertyName} = new ${at.attributeType.typeName.className}(${at.constructorArguments});`); + } } - /** - * We resolve here. - * - * Since resolve() deep-resolves, we only need to do this once. - */ - private emitCloudFormationPropertiesOverride(propsType: genspec.CodeName) { - this.code.openBlock(`public get propertyOverrides(): ${propsType.className}`); - this.code.line(`return this.untypedPropertyOverrides;`); - this.code.closeBlock(); + this.code.closeBlock(); - this.code.openBlock('protected renderProperties(properties: any): { [key: string]: any } '); - this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(properties));`); - this.code.closeBlock(); + if (propsType) { + this.code.line(); + this.emitCloudFormationPropertiesOverride(propsType); } - /** - * Emit the function that is going to map the generated TypeScript object back into the schema that CloudFormation expects - * - * The generated code looks like this: - * - * function bucketPropsToCloudFormation(properties: any): any { - * if (!cdk.canInspect(properties)) return properties; - * BucketPropsValidator(properties).assertSuccess(); - * return { - * AccelerateConfiguration: bucketAccelerateConfigurationPropertyToCloudFormation(properties.accelerateConfiguration), - * AccessControl: cdk.stringToCloudFormation(properties.accessControl), - * AnalyticsConfigurations: cdk.listMapper(bucketAnalyticsConfigurationPropertyToCloudFormation) - * (properties.analyticsConfigurations), - * // ... - * }; - * } - * - * Generated as a top-level function outside any namespace so we can hide it from library consumers. - */ - private emitCloudFormationMapper(typeName: genspec.CodeName, - propSpecs: { [name: string]: schema.Property }, - nameConversionTable: Dictionary) { - const mapperName = genspec.cfnMapperName(typeName); - - this.code.line('/**'); - this.code.line(` * Renders the AWS CloudFormation properties of an ${quoteCode(typeName.specName!.fqn)} resource`); - this.code.line(' *'); - this.code.line(` * @param properties the TypeScript properties of a ${quoteCode(typeName.className)}`); - this.code.line(' *'); - this.code.line(` * @returns the AWS CloudFormation properties of an ${quoteCode(typeName.specName!.fqn)} resource.`); - this.code.line(' */'); - - this.code.openBlock(`function ${mapperName.functionName}(properties: any): any`); - - // It might be that this value is 'null' or 'undefined', and that that's OK. Simply return - // the falsey value, the upstream struct is in a better position to know whether this is required or not. - this.code.line(`if (!${CORE}.canInspect(properties)) { return properties; }`); - - // Do a 'type' check first - const validatorName = genspec.validatorName(typeName); - this.code.line(`${validatorName.fqn}(properties).assertSuccess();`); - - // Generate the return object - this.code.line('return {'); - - const self = this; - Object.keys(nameConversionTable).forEach(cfnName => { - const propName = nameConversionTable[cfnName]; - const propSpec = propSpecs[cfnName]; - - const mapperExpression = genspec.typeDispatch(typeName.specName!, propSpec, { - visitScalar(type: genspec.CodeName) { - return mapperNames([type]); - }, - visitUnionScalar(types: genspec.CodeName[]) { - return `${CORE}.unionMapper([${validatorNames(types)}], [${mapperNames(types)}])`; - }, - visitList(itemType: genspec.CodeName) { - return `${CORE}.listMapper(${mapperNames([itemType])})`; - }, - visitUnionList(itemTypes: genspec.CodeName[]) { - return `${CORE}.listMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; - }, - visitMap(itemType: genspec.CodeName) { - return `${CORE}.hashMapper(${mapperNames([itemType])})`; - }, - visitUnionMap(itemTypes: genspec.CodeName[]) { - return `${CORE}.hashMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; - }, - visitListOrScalar(types: genspec.CodeName[], itemTypes: genspec.CodeName[]) { - const scalarValidator = `${CORE}.unionValidator(${validatorNames(types)})`; - const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; - const scalarMapper = `${CORE}.unionMapper([${validatorNames(types)}], [${mapperNames(types)}])`; - const listMapper = `${CORE}.listMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; - - return `${CORE}.unionMapper([${scalarValidator}, ${listValidator}], [${scalarMapper}, ${listMapper}])`; - }, - }); - - self.code.line(` ${cfnName}: ${mapperExpression}(properties.${propName}),`); - }); - this.code.line('};'); - this.code.closeBlock(); + this.closeClass(resourceName); + + this.endNamespace(resourceName); + + return attributeTypes; + } + + /** + * We resolve here. + * + * Since resolve() deep-resolves, we only need to do this once. + */ + private emitCloudFormationPropertiesOverride(propsType: genspec.CodeName) { + this.code.openBlock(`public get propertyOverrides(): ${propsType.className}`); + this.code.line(`return this.untypedPropertyOverrides;`); + this.code.closeBlock(); + + this.code.openBlock('protected renderProperties(properties: any): { [key: string]: any } '); + this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(${CORE}.resolve(properties));`); + this.code.closeBlock(); + } + + /** + * Emit the function that is going to map the generated TypeScript object back into the schema that CloudFormation expects + * + * The generated code looks like this: + * + * function bucketPropsToCloudFormation(properties: any): any { + * if (!cdk.canInspect(properties)) return properties; + * BucketPropsValidator(properties).assertSuccess(); + * return { + * AccelerateConfiguration: bucketAccelerateConfigurationPropertyToCloudFormation(properties.accelerateConfiguration), + * AccessControl: cdk.stringToCloudFormation(properties.accessControl), + * AnalyticsConfigurations: cdk.listMapper(bucketAnalyticsConfigurationPropertyToCloudFormation) + * (properties.analyticsConfigurations), + * // ... + * }; + * } + * + * Generated as a top-level function outside any namespace so we can hide it from library consumers. + */ + private emitCloudFormationMapper(typeName: genspec.CodeName, + propSpecs: { [name: string]: schema.Property }, + nameConversionTable: Dictionary) { + const mapperName = genspec.cfnMapperName(typeName); + + this.code.line('/**'); + this.code.line(` * Renders the AWS CloudFormation properties of an ${quoteCode(typeName.specName!.fqn)} resource`); + this.code.line(' *'); + this.code.line(` * @param properties the TypeScript properties of a ${quoteCode(typeName.className)}`); + this.code.line(' *'); + this.code.line(` * @returns the AWS CloudFormation properties of an ${quoteCode(typeName.specName!.fqn)} resource.`); + this.code.line(' */'); + + this.code.openBlock(`function ${mapperName.functionName}(properties: any): any`); + + // It might be that this value is 'null' or 'undefined', and that that's OK. Simply return + // the falsey value, the upstream struct is in a better position to know whether this is required or not. + this.code.line(`if (!${CORE}.canInspect(properties)) { return properties; }`); + + // Do a 'type' check first + const validatorName = genspec.validatorName(typeName); + this.code.line(`${validatorName.fqn}(properties).assertSuccess();`); + + // Generate the return object + this.code.line('return {'); + + const self = this; + Object.keys(nameConversionTable).forEach(cfnName => { + const propName = nameConversionTable[cfnName]; + const propSpec = propSpecs[cfnName]; + + const mapperExpression = genspec.typeDispatch(typeName.specName!, propSpec, { + visitScalar(type: genspec.CodeName) { + return mapperNames([type]); + }, + visitUnionScalar(types: genspec.CodeName[]) { + return `${CORE}.unionMapper([${validatorNames(types)}], [${mapperNames(types)}])`; + }, + visitList(itemType: genspec.CodeName) { + return `${CORE}.listMapper(${mapperNames([itemType])})`; + }, + visitUnionList(itemTypes: genspec.CodeName[]) { + return `${CORE}.listMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; + }, + visitMap(itemType: genspec.CodeName) { + return `${CORE}.hashMapper(${mapperNames([itemType])})`; + }, + visitUnionMap(itemTypes: genspec.CodeName[]) { + return `${CORE}.hashMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; + }, + visitListOrScalar(types: genspec.CodeName[], itemTypes: genspec.CodeName[]) { + const scalarValidator = `${CORE}.unionValidator(${validatorNames(types)})`; + const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; + const scalarMapper = `${CORE}.unionMapper([${validatorNames(types)}], [${mapperNames(types)}])`; + const listMapper = `${CORE}.listMapper(${CORE}.unionMapper([${validatorNames(itemTypes)}], [${mapperNames(itemTypes)}]))`; + + return `${CORE}.unionMapper([${scalarValidator}, ${listValidator}], [${scalarMapper}, ${listMapper}])`; + }, + }); + + self.code.line(` ${cfnName}: ${mapperExpression}(properties.${propName}),`); + }); + this.code.line('};'); + this.code.closeBlock(); + } + + /** + * Emit a function that will validate whether the given property bag matches the schema of this complex type + * + * Generated as a top-level function outside any namespace so we can hide it from library consumers. + */ + private emitValidator(typeName: genspec.CodeName, + propSpecs: { [name: string]: schema.Property }, + nameConversionTable: Dictionary) { + const validatorName = genspec.validatorName(typeName); + + this.code.line('/**'); + this.code.line(` * Determine whether the given properties match those of a ${quoteCode(typeName.className)}`); + this.code.line(' *'); + this.code.line(` * @param properties the TypeScript properties of a ${quoteCode(typeName.className)}`); + this.code.line(' *'); + this.code.line(' * @returns the result of the validation.'); + this.code.line(' */'); + this.code.openBlock(`function ${validatorName.functionName}(properties: any): ${CORE}.ValidationResult`); + this.code.line(`if (!${CORE}.canInspect(properties)) { return ${CORE}.VALIDATION_SUCCESS; }`); + + this.code.line(`const errors = new ${CORE}.ValidationResults();`); + + Object.keys(propSpecs).forEach(cfnPropName => { + const propSpec = propSpecs[cfnPropName]; + const propName = nameConversionTable[cfnPropName]; + + if (propSpec.Required) { + this.code.line(`errors.collect(${CORE}.propertyValidator('${propName}', ${CORE}.requiredValidator)(properties.${propName}));`); + } + + const self = this; + const validatorExpression = genspec.typeDispatch(typeName.specName!, propSpec, { + visitScalar(type: genspec.CodeName) { + return validatorNames([type]); + }, + visitUnionScalar(types: genspec.CodeName[]) { + return `${CORE}.unionValidator(${validatorNames(types)})`; + }, + visitList(itemType: genspec.CodeName) { + return `${CORE}.listValidator(${validatorNames([itemType])})`; + }, + visitUnionList(itemTypes: genspec.CodeName[]) { + return `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; + }, + visitMap(itemType: genspec.CodeName) { + return `${CORE}.hashValidator(${validatorNames([itemType])})`; + }, + visitUnionMap(itemTypes: genspec.CodeName[]) { + return `${CORE}.hashValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; + }, + visitListOrScalar(types: genspec.CodeName[], itemTypes: genspec.CodeName[]) { + const scalarValidator = `${CORE}.unionValidator(${validatorNames(types)})`; + const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; + + return `${CORE}.unionValidator(${scalarValidator}, ${listValidator})`; + }, + }); + self.code.line(`errors.collect(${CORE}.propertyValidator('${propName}', ${validatorExpression})(properties.${propName}));`); + }); + + this.code.line(`return errors.wrap('supplied properties not correct for "${typeName.className}"');`); + + this.code.closeBlock(); + } + + /** + * Attribute types are classes that represent resource attributes (e.g. QueueArnAttribute). + */ + private emitAttributeType(attr: genspec.AttributeTypeDeclaration) { + if (!attr.baseClassName) { + return; // primitive, no attribute type generated } - /** - * Emit a function that will validate whether the given property bag matches the schema of this complex type - * - * Generated as a top-level function outside any namespace so we can hide it from library consumers. - */ - private emitValidator(typeName: genspec.CodeName, - propSpecs: { [name: string]: schema.Property }, - nameConversionTable: Dictionary) { - const validatorName = genspec.validatorName(typeName); - - this.code.line('/**'); - this.code.line(` * Determine whether the given properties match those of a ${quoteCode(typeName.className)}`); - this.code.line(' *'); - this.code.line(` * @param properties the TypeScript properties of a ${quoteCode(typeName.className)}`); - this.code.line(' *'); - this.code.line(' * @returns the result of the validation.'); - this.code.line(' */'); - this.code.openBlock(`function ${validatorName.functionName}(properties: any): ${CORE}.ValidationResult`); - this.code.line(`if (!${CORE}.canInspect(properties)) { return ${CORE}.VALIDATION_SUCCESS; }`); - - this.code.line(`const errors = new ${CORE}.ValidationResults();`); - - Object.keys(propSpecs).forEach(cfnPropName => { - const propSpec = propSpecs[cfnPropName]; - const propName = nameConversionTable[cfnPropName]; - - if (propSpec.Required) { - this.code.line(`errors.collect(${CORE}.propertyValidator('${propName}', ${CORE}.requiredValidator)(properties.${propName}));`); - } - - const self = this; - const validatorExpression = genspec.typeDispatch(typeName.specName!, propSpec, { - visitScalar(type: genspec.CodeName) { - return validatorNames([type]); - }, - visitUnionScalar(types: genspec.CodeName[]) { - return `${CORE}.unionValidator(${validatorNames(types)})`; - }, - visitList(itemType: genspec.CodeName) { - return `${CORE}.listValidator(${validatorNames([itemType])})`; - }, - visitUnionList(itemTypes: genspec.CodeName[]) { - return `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; - }, - visitMap(itemType: genspec.CodeName) { - return `${CORE}.hashValidator(${validatorNames([itemType])})`; - }, - visitUnionMap(itemTypes: genspec.CodeName[]) { - return `${CORE}.hashValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; - }, - visitListOrScalar(types: genspec.CodeName[], itemTypes: genspec.CodeName[]) { - const scalarValidator = `${CORE}.unionValidator(${validatorNames(types)})`; - const listValidator = `${CORE}.listValidator(${CORE}.unionValidator(${validatorNames(itemTypes)}))`; - - return `${CORE}.unionValidator(${scalarValidator}, ${listValidator})`; - }, - }); - self.code.line(`errors.collect(${CORE}.propertyValidator('${propName}', ${validatorExpression})(properties.${propName}));`); - }); - - this.code.line(`return errors.wrap('supplied properties not correct for "${typeName.className}"');`); - - this.code.closeBlock(); + this.code.line(); + this.openClass(attr.typeName, attr.docLink, attr.baseClassName.fqn); + // Add a private member that will make the class structurally + // different in TypeScript, which prevents assigning returning + // incorrectly-typed Tokens. Those will cause ClassCastExceptions + // in strictly-typed languages. + this.code.line('// @ts-ignore: private but unused on purpose.'); + this.code.line(`private readonly thisIsA${attr.typeName.className} = true;`); + + this.closeClass(attr.typeName); + } + + private emitProperty(context: genspec.CodeName, propName: string, spec: schema.Property, additionalDocs: string): string { + const question = spec.Required ? '' : '?'; + const javascriptPropertyName = genspec.cloudFormationToScriptName(propName); + + this.docLink(spec.Documentation, additionalDocs); + this.code.line(`${javascriptPropertyName}${question}: ${this.findNativeType(context, spec)};`); + + return javascriptPropertyName; + } + private beginNamespace(type: genspec.CodeName) { + if (type.namespace) { + const parts = type.namespace.split('.'); + for (const part of parts) { + this.code.openBlock(`export namespace ${part}`); + } } + } - /** - * Attribute types are classes that represent resource attributes (e.g. QueueArnAttribute). - */ - private emitAttributeType(attr: genspec.AttributeTypeDeclaration) { - if (!attr.baseClassName) { - return; // primitive, no attribute type generated - } - - this.code.line(); - this.openClass(attr.typeName, attr.docLink, attr.baseClassName.fqn); - // Add a private member that will make the class structurally - // different in TypeScript, which prevents assigning returning - // incorrectly-typed Tokens. Those will cause ClassCastExceptions - // in strictly-typed languages. - this.code.line('// @ts-ignore: private but unused on purpose.'); - this.code.line(`private readonly thisIsA${attr.typeName.className} = true;`); - - this.closeClass(attr.typeName); + private endNamespace(type: genspec.CodeName) { + if (type.namespace) { + const parts = type.namespace.split('.'); + for (const _ of parts) { + this.code.closeBlock(); + } } + } - private emitProperty(context: genspec.CodeName, propName: string, spec: schema.Property, additionalDocs: string): string { - const question = spec.Required ? '' : '?'; - const javascriptPropertyName = genspec.cloudFormationToScriptName(propName); - - this.docLink(spec.Documentation, additionalDocs); - this.code.line(`${javascriptPropertyName}${question}: ${this.findNativeType(context, spec)};`); + private emitPropertyType(typeName: genspec.CodeName, propTypeSpec: schema.PropertyType) { + this.code.line(); + this.beginNamespace(typeName); - return javascriptPropertyName; + this.docLink(propTypeSpec.Documentation); + if (!propTypeSpec.Properties || Object.keys(propTypeSpec.Properties).length === 0) { + this.code.line(`// tslint:disable-next-line:no-empty-interface | A genuine empty-object type`); } - private beginNamespace(type: genspec.CodeName) { - if (type.namespace) { - const parts = type.namespace.split('.'); - for (const part of parts) { - this.code.openBlock(`export namespace ${part}`); - } - } + this.code.openBlock(`export interface ${typeName.className}`); + const conversionTable: Dictionary = {}; + if (propTypeSpec.Properties) { + Object.keys(propTypeSpec.Properties).forEach(propName => { + const propSpec = propTypeSpec.Properties[propName]; + const additionalDocs = quoteCode(`${typeName.fqn}.${propName}`); + const newName = this.emitProperty(typeName, propName, propSpec, additionalDocs); + conversionTable[propName] = newName; + }); } - private endNamespace(type: genspec.CodeName) { - if (type.namespace) { - const parts = type.namespace.split('.'); - for (const _ of parts) { - this.code.closeBlock(); - } + this.code.closeBlock(); + this.endNamespace(typeName); + + this.code.line(); + this.emitValidator(typeName, propTypeSpec.Properties, conversionTable); + this.code.line(); + this.emitCloudFormationMapper(typeName, propTypeSpec.Properties, conversionTable); + } + + /** + * Return the native type expression for the given propSpec + */ + private findNativeType(resource: genspec.CodeName, propSpec: schema.Property): string { + const alternatives: string[] = []; + + if (schema.isCollectionProperty(propSpec)) { + // render the union of all item types + const itemTypes = genspec.specTypesToCodeTypes(resource.specName!, itemTypeNames(propSpec)); + // Always accept a token in place of any list element + itemTypes.push(genspec.TOKEN_NAME); + + const union = this.renderTypeUnion(resource, itemTypes); + + if (schema.isMapProperty(propSpec)) { + alternatives.push(`{ [key: string]: (${union}) }`); + } else { + // To make TSLint happy, we have to either emit: SingleType[] or Array + + if (union.indexOf('|') !== -1) { + alternatives.push(`Array<${union}>`); + } else { + alternatives.push(`(${union})[]`); } + } } - private emitPropertyType(typeName: genspec.CodeName, propTypeSpec: schema.PropertyType) { - this.code.line(); - this.beginNamespace(typeName); - - this.docLink(propTypeSpec.Documentation); - if (!propTypeSpec.Properties || Object.keys(propTypeSpec.Properties).length === 0) { - this.code.line(`// tslint:disable-next-line:no-empty-interface | A genuine empty-object type`); - } - this.code.openBlock(`export interface ${typeName.className}`); - const conversionTable: Dictionary = {}; - if (propTypeSpec.Properties) { - Object.keys(propTypeSpec.Properties).forEach(propName => { - const propSpec = propTypeSpec.Properties[propName]; - const additionalDocs = quoteCode(`${typeName.fqn}.${propName}`); - const newName = this.emitProperty(typeName, propName, propSpec, additionalDocs); - conversionTable[propName] = newName; - }); - } - - this.code.closeBlock(); - this.endNamespace(typeName); - - this.code.line(); - this.emitValidator(typeName, propTypeSpec.Properties, conversionTable); - this.code.line(); - this.emitCloudFormationMapper(typeName, propTypeSpec.Properties, conversionTable); + // Yes, some types can be both collection and scalar. Looking at you, SAM. + if (schema.isScalarPropery(propSpec)) { + // Scalar type + const typeNames = scalarTypeNames(propSpec); + const types = genspec.specTypesToCodeTypes(resource.specName!, typeNames); + alternatives.push(this.renderTypeUnion(resource, types)); } - /** - * Return the native type expression for the given propSpec - */ - private findNativeType(resource: genspec.CodeName, propSpec: schema.Property): string { - const alternatives: string[] = []; - - if (schema.isCollectionProperty(propSpec)) { - // render the union of all item types - const itemTypes = genspec.specTypesToCodeTypes(resource.specName!, itemTypeNames(propSpec)); - // Always accept a token in place of any list element - itemTypes.push(genspec.TOKEN_NAME); - - const union = this.renderTypeUnion(resource, itemTypes); - - if (schema.isMapProperty(propSpec)) { - alternatives.push(`{ [key: string]: (${union}) }`); - } else { - // To make TSLint happy, we have to either emit: SingleType[] or Array - - if (union.indexOf('|') !== -1) { - alternatives.push(`Array<${union}>`); - } else { - alternatives.push(`(${union})[]`); - } - } - } + // Always + alternatives.push(genspec.TOKEN_NAME.fqn); - // Yes, some types can be both collection and scalar. Looking at you, SAM. - if (schema.isScalarPropery(propSpec)) { - // Scalar type - const typeNames = scalarTypeNames(propSpec); - const types = genspec.specTypesToCodeTypes(resource.specName!, typeNames); - alternatives.push(this.renderTypeUnion(resource, types)); - } + return alternatives.join(' | '); + } - // Always - alternatives.push(genspec.TOKEN_NAME.fqn); + private renderTypeUnion(context: genspec.CodeName, types: genspec.CodeName[]) { + return types.map((type) => type.relativeTo(context).fqn).join(' | '); + } - return alternatives.join(' | '); + private docLink(link: string | undefined, ...before: string[]) { + if (!link && before.length === 0) { return; } + this.code.line('/**'); + before.forEach(line => this.code.line(` * ${line}`)); + if (link) { + this.code.line(` * @link ${link}`); } + this.code.line(' */'); + return; + } - private renderTypeUnion(context: genspec.CodeName, types: genspec.CodeName[]) { - return types.map((type) => type.relativeTo(context).fqn).join(' | '); - } - - private docLink(link: string | undefined, ...before: string[]) { - if (!link && before.length === 0) { return; } - this.code.line('/**'); - before.forEach(line => this.code.line(` * ${line}`)); - if (link) { - this.code.line(` * @link ${link}`); - } - this.code.line(' */'); - return; - } - - private validateRefKindPresence(name: string, resourceType: schema.ResourceType): any { - if (!resourceType.RefKind) { // Both empty string and undefined - throw new Error(`Resource ${name} does not have a RefKind; please annotate this new resources in @aws-cdk/cfnspec`); - } + private validateRefKindPresence(name: string, resourceType: schema.ResourceType): any { + if (!resourceType.RefKind) { // Both empty string and undefined + throw new Error(`Resource ${name} does not have a RefKind; please annotate this new resources in @aws-cdk/cfnspec`); } + } } /** * Return a comma-separated list of validator functions for the given types */ function validatorNames(types: genspec.CodeName[]): string { - return types.map(type => genspec.validatorName(type).fqn).join(', '); + return types.map(type => genspec.validatorName(type).fqn).join(', '); } /** * Return a comma-separated list of mapper functions for the given types */ function mapperNames(types: genspec.CodeName[]): string { - return types.map(type => genspec.cfnMapperName(type).fqn).join(', '); + return types.map(type => genspec.cfnMapperName(type).fqn).join(', '); } /** @@ -644,7 +644,7 @@ function mapperNames(types: genspec.CodeName[]): string { * (We can leave the name PascalCased, as it's going to be camelCased later). */ function resourceNameProperty(resourceName: SpecName) { - return `${resourceName.resourceName}Name`; + return `${resourceName.resourceName}Name`; } /** @@ -656,5 +656,5 @@ function resourceNameProperty(resourceName: SpecName) { * @returns the code name surrounded by double backticks. */ function quoteCode(code: string): string { - return '``' + code + '``'; + return '``' + code + '``'; } From eb0e9f60401d5bd54865908228beb6b0e8fa503c Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 22:20:59 +0300 Subject: [PATCH 4/6] Fix custom-resource --- packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts b/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts index 69a85dd404dfc..32854366bc3ab 100644 --- a/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts +++ b/packages/@aws-cdk/aws-cloudformation/lib/custom-resource.ts @@ -62,8 +62,8 @@ export class CustomResource extends cloudformation.CustomResource { /** * Override renderProperties to mix in the user-defined properties */ - protected renderProperties(): {[key: string]: any} { - const props = super.renderProperties(); + protected renderProperties(properties: any): {[key: string]: any} { + const props = super.renderProperties(properties); return Object.assign(props, uppercaseProperties(this.userProperties || {})); } From 14016d9342929e5c9acbccbce560793102b02f6b Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 22:46:13 +0300 Subject: [PATCH 5/6] Fix merge with untyped overrides --- .../cdk/lib/cloudformation/resource.ts | 7 +- .../cdk/test/cloudformation/test.resource.ts | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts index 978967667726a..a8c90de6e6348 100644 --- a/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts +++ b/packages/@aws-cdk/cdk/lib/cloudformation/resource.ts @@ -176,15 +176,14 @@ export class Resource extends Referenceable { */ public toCloudFormation(): object { try { - - const properties = ignoreEmpty(this.renderProperties(this.properties)) || { }; - const overrides = ignoreEmpty(this.renderProperties(this.untypedPropertyOverrides)) || { }; + // merge property overrides onto properties and then render (and validate). + const properties = this.renderProperties(deepMerge(this.properties || { }, this.untypedPropertyOverrides)); return { Resources: { [this.logicalId]: deepMerge({ Type: this.resourceType, - Properties: ignoreEmpty(deepMerge(properties, overrides)), + Properties: ignoreEmpty(properties), DependsOn: ignoreEmpty(this.renderDependsOn()), CreationPolicy: capitalizePropertyNames(this.options.creationPolicy), UpdatePolicy: capitalizePropertyNames(this.options.updatePolicy), diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index 7ffd6cc936be5..6c8536948e064 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -521,6 +521,54 @@ export = { { Type: 'AWS::Resource::Type', Properties: { Hello: { World: { Hey: 'Jude' } } } } } }); test.done(); + }, + + untypedPropertyOverrides: { + + 'can be used by derived classes to specify overrides before render()'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource', { + prop1: 'foo' + }); + + r.setProperty('prop2', 'bar'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP1: 'foo', PROP2: 'bar' } } } }); + test.done(); + }, + + '"properties" is undefined'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource'); + + r.setProperty('prop3', 'zoo'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP3: 'zoo' } } } }); + test.done(); + }, + + '"properties" is empty'(test: Test) { + const stack = new Stack(); + + const r = new CustomizableResource(stack, 'MyResource', { }); + + r.setProperty('prop3', 'zoo'); + r.setProperty('prop2', 'hey'); + + test.deepEqual(stack.toCloudFormation(), { Resources: + { MyResource: + { Type: 'MyResourceType', + Properties: { PROP2: 'hey', PROP3: 'zoo' } } } }); + test.done(); + } } } }; @@ -548,3 +596,21 @@ class Counter extends Resource { function withoutHash(logId: string) { return logId.substr(0, logId.length - 8); } + +class CustomizableResource extends Resource { + constructor(parent: Construct, id: string, props?: any) { + super(parent, id, { type: 'MyResourceType', properties: props }); + } + + public setProperty(key: string, value: any) { + this.untypedPropertyOverrides[key] = value; + } + + public renderProperties(properties: any) { + return { + PROP1: properties.prop1, + PROP2: properties.prop2, + PROP3: properties.prop3 + }; + } +} From fe938878deb09ef6906a15ef11852b0906d76737 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 27 Sep 2018 22:46:46 +0300 Subject: [PATCH 6/6] Add quotes --- packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts index 6c8536948e064..56421e9fc6b02 100644 --- a/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts +++ b/packages/@aws-cdk/cdk/test/cloudformation/test.resource.ts @@ -523,7 +523,7 @@ export = { test.done(); }, - untypedPropertyOverrides: { + 'untypedPropertyOverrides': { 'can be used by derived classes to specify overrides before render()'(test: Test) { const stack = new Stack();