diff --git a/packages/@aws-cdk/mixins-preview/README.md b/packages/@aws-cdk/mixins-preview/README.md index ed7be4dc9b8d1..d6ebf32b99c91 100644 --- a/packages/@aws-cdk/mixins-preview/README.md +++ b/packages/@aws-cdk/mixins-preview/README.md @@ -37,6 +37,22 @@ Mixins.of(bucket) .apply(new AutoDeleteObjects()); ``` +### Fluent Syntax with `.with()` + +For convenience, you can use the `.with()` method for a more fluent syntax: + +```typescript +import '@aws-cdk/mixins-preview/with'; + +const bucket = new s3.CfnBucket(scope, "MyBucket") + .with(new EnableVersioning()) + .with(new AutoDeleteObjects()); +``` + +The `.with()` method is available after importing `@aws-cdk/mixins-preview/with`, which augments all constructs with this method. It provides the same functionality as `Mixins.of().apply()` but with a more chainable API. + +> **Note**: The `.with()` fluent syntax is only available in JavaScript and TypeScript. Other jsii languages (Python, Java, C#, and Go) should use the `Mixins.of(...).mustApply()` syntax instead. The import requirement is temporary during the preview phase. Once the API is stable, the `.with()` method will be available by default on all constructs and in all languages. + ## Creating Custom Mixins Mixins are simple classes that implement the `IMixin` interface: diff --git a/packages/@aws-cdk/mixins-preview/lib/core/applicator.ts b/packages/@aws-cdk/mixins-preview/lib/core/applicator.ts new file mode 100644 index 0000000000000..9df1a06d7e98d --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/core/applicator.ts @@ -0,0 +1,59 @@ +import type { IConstruct } from 'constructs'; +import { ValidationError } from 'aws-cdk-lib/core'; +import type { IMixin } from './mixins'; +import { ConstructSelector } from './selectors'; + +/** + * Applies mixins to constructs. + */ +export class MixinApplicator { + private readonly scope: IConstruct; + private readonly selector: ConstructSelector; + + constructor( + scope: IConstruct, + selector: ConstructSelector = ConstructSelector.all(), + ) { + this.scope = scope; + this.selector = selector; + } + + /** + * Applies a mixin to selected constructs. + */ + apply(mixin: IMixin): this { + const constructs = this.selector.select(this.scope); + for (const construct of constructs) { + if (mixin.supports(construct)) { + const errors = mixin.validate?.(construct) ?? []; + if (errors.length > 0) { + throw new ValidationError(`Mixin validation failed: ${errors.join(', ')}`, this.scope); + } + mixin.applyTo(construct); + } + } + return this; + } + + /** + * Applies a mixin and requires that it be applied to at least one construct. + */ + mustApply(mixin: IMixin): this { + const constructs = this.selector.select(this.scope); + let applied = false; + for (const construct of constructs) { + if (mixin.supports(construct)) { + const errors = mixin.validate?.(construct) ?? []; + if (errors.length > 0) { + throw new ValidationError(`Mixin validation failed: ${errors.join(', ')}`, construct); + } + mixin.applyTo(construct); + applied = true; + } + } + if (!applied) { + throw new ValidationError(`Mixin ${mixin.constructor.name} could not be applied to any constructs`, this.scope); + } + return this; + } +} diff --git a/packages/@aws-cdk/mixins-preview/lib/core/index.ts b/packages/@aws-cdk/mixins-preview/lib/core/index.ts new file mode 100644 index 0000000000000..4d35b575e4910 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/core/index.ts @@ -0,0 +1,4 @@ +// Re-export all core functionality from separate modules +export * from './mixins'; +export * from './selectors'; +export * from './applicator'; diff --git a/packages/@aws-cdk/mixins-preview/lib/core/mixins.ts b/packages/@aws-cdk/mixins-preview/lib/core/mixins.ts new file mode 100644 index 0000000000000..07c8383cc8292 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/core/mixins.ts @@ -0,0 +1,68 @@ +import type { IConstruct } from 'constructs'; +import type { ConstructSelector } from './selectors'; +import { MixinApplicator } from './applicator'; + +// this will change when we update the interface to deliberately break compatibility checks +const MIXIN_SYMBOL = Symbol.for('@aws-cdk/mixins-preview.Mixin.pre1'); + +/** + * Main entry point for applying mixins. + */ +export class Mixins { + /** + * Creates a MixinApplicator for the given scope. + */ + static of(scope: IConstruct, selector?: ConstructSelector): MixinApplicator { + return new MixinApplicator(scope, selector); + } +} + +/** + * A mixin is a reusable piece of functionality that can be applied to constructs + * to add behavior, properties, or modify existing functionality without inheritance. + */ +export interface IMixin { + /** + * Determines whether this mixin can be applied to the given construct. + */ + supports(construct: IConstruct): boolean; + + /** + * Validates the construct before applying the mixin. + */ + validate?(construct: IConstruct): string[]; + + /** + * Applies the mixin functionality to the target construct. + */ + applyTo(construct: IConstruct): IConstruct; +} + +/** + * Abstract base class for mixins that provides default implementations. + */ +export abstract class Mixin implements IMixin { + /** + * Checks if `x` is a Mixin. + * + * @param x Any object + * @returns true if `x` is an object created from a class which extends `Mixin`. + */ + static isMixin(x: any): x is Mixin { + return x != null && typeof x === 'object' && MIXIN_SYMBOL in x; + } + + constructor() { + Object.defineProperty(this, MIXIN_SYMBOL, { value: true }); + } + + public supports(_construct: IConstruct): boolean { + return true; + } + + public validate(_construct: IConstruct): string[] { + return []; + } + + abstract applyTo(construct: IConstruct): IConstruct; +} diff --git a/packages/@aws-cdk/mixins-preview/lib/core/selectors.ts b/packages/@aws-cdk/mixins-preview/lib/core/selectors.ts new file mode 100644 index 0000000000000..4bb040e585c79 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/core/selectors.ts @@ -0,0 +1,109 @@ +import type { IConstruct } from 'constructs'; +import { CfnResource } from 'aws-cdk-lib/core'; + +/** + * Selects constructs from a construct tree based on various criteria. + */ +export abstract class ConstructSelector { + /** + * Selects all constructs in the tree. + */ + static all(): ConstructSelector { + return new AllConstructsSelector(); + } + + /** + * Selects CfnResource constructs or the default CfnResource child. + */ + static cfnResource(): ConstructSelector { + return new CfnResourceSelector(); + } + + /** + * Selects constructs of a specific type. + */ + static resourcesOfType(type: string | any): ConstructSelector { + return new ResourceTypeSelector(type); + } + + /** + * Selects constructs whose IDs match a pattern. + */ + static byId(pattern: any): ConstructSelector { + return new IdPatternSelector(pattern); + } + + /** + * Selects constructs from the given scope based on the selector's criteria. + */ + abstract select(scope: IConstruct): IConstruct[]; +} + +class AllConstructsSelector extends ConstructSelector { + select(scope: IConstruct): IConstruct[] { + return scope.node.findAll(); + } +} + +class CfnResourceSelector extends ConstructSelector { + select(scope: IConstruct): IConstruct[] { + if (CfnResource.isCfnResource(scope)) { + return [scope]; + } + const defaultChild = scope.node.defaultChild; + if (CfnResource.isCfnResource(defaultChild)) { + return [defaultChild]; + } + return []; + } +} + +class ResourceTypeSelector extends ConstructSelector { + constructor(private readonly type: string | any) { + super(); + } + + select(scope: IConstruct): IConstruct[] { + const result: IConstruct[] = []; + const visit = (node: IConstruct) => { + if (typeof this.type === 'string') { + if (CfnResource.isCfnResource(node) && node.cfnResourceType === this.type) { + result.push(node); + } + } else if ('isCfnResource' in this.type && 'CFN_RESOURCE_TYPE_NAME' in this.type) { + if (CfnResource.isCfnResource(node) && node.cfnResourceType === this.type.CFN_RESOURCE_TYPE_NAME) { + result.push(node); + } + } else { + if (node instanceof this.type) { + result.push(node); + } + } + for (const child of node.node.children) { + visit(child); + } + }; + visit(scope); + return result; + } +} + +class IdPatternSelector extends ConstructSelector { + constructor(private readonly pattern: any) { + super(); + } + + select(scope: IConstruct): IConstruct[] { + const result: IConstruct[] = []; + const visit = (node: IConstruct) => { + if (this.pattern && typeof this.pattern.test === 'function' && this.pattern.test(node.node.id)) { + result.push(node); + } + for (const child of node.node.children) { + visit(child); + } + }; + visit(scope); + return result; + } +} diff --git a/packages/@aws-cdk/mixins-preview/lib/custom-resource-handlers/aws-s3/auto-delete-objects-provider.ts b/packages/@aws-cdk/mixins-preview/lib/custom-resource-handlers/aws-s3/auto-delete-objects-provider.ts new file mode 100644 index 0000000000000..df0e146097537 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/custom-resource-handlers/aws-s3/auto-delete-objects-provider.ts @@ -0,0 +1,26 @@ +import * as path from 'path'; +import type { Construct } from 'constructs'; +import type { CustomResourceProviderOptions } from 'aws-cdk-lib/core'; +import { Stack, CustomResourceProviderBase, determineLatestNodeRuntimeName } from 'aws-cdk-lib/core'; + +export class AutoDeleteObjectsProvider extends CustomResourceProviderBase { + public static getOrCreate(scope: Construct, uniqueid: string, props?: CustomResourceProviderOptions): string { + return this.getOrCreateProvider(scope, uniqueid, props).serviceToken; + } + + public static getOrCreateProvider(scope: Construct, uniqueid: string, props?: CustomResourceProviderOptions): AutoDeleteObjectsProvider { + const id = `${uniqueid}CustomResourceProvider`; + const stack = Stack.of(scope); + const existing = stack.node.tryFindChild(id) as AutoDeleteObjectsProvider; + return existing ?? new AutoDeleteObjectsProvider(stack, id, props); + } + + private constructor(scope: Construct, id: string, props?: CustomResourceProviderOptions) { + super(scope, id, { + ...props, + codeDirectory: path.join(__dirname, '..', 'dist', 'aws-s3', 'auto-delete-objects-handler'), + runtimeName: determineLatestNodeRuntimeName(scope), + }); + this.node.addMetadata('aws:cdk:is-custom-resource-handler-customResourceProvider', true); + } +} diff --git a/packages/@aws-cdk/mixins-preview/lib/index.ts b/packages/@aws-cdk/mixins-preview/lib/index.ts index 2b3d8cffd6894..f750ef6449bb1 100644 --- a/packages/@aws-cdk/mixins-preview/lib/index.ts +++ b/packages/@aws-cdk/mixins-preview/lib/index.ts @@ -1 +1,2 @@ -export * from './mixins'; +export * as core from './core'; +export * as mixins from './mixins'; diff --git a/packages/@aws-cdk/mixins-preview/lib/mixins/index.ts b/packages/@aws-cdk/mixins-preview/lib/mixins/index.ts index 5425705409c13..f6ff7be743a85 100644 --- a/packages/@aws-cdk/mixins-preview/lib/mixins/index.ts +++ b/packages/@aws-cdk/mixins-preview/lib/mixins/index.ts @@ -1,73 +1 @@ -import type { IConstruct } from 'constructs'; - -/** - * A mixin is a reusable piece of functionality that can be applied to constructs - * to add behavior, properties, or modify existing functionality without inheritance. - * - * Mixins follow a three-phase pattern: - * 1. Check if the mixin supports the target construct (supports) - * 2. Optionally validate the construct before applying (validate) - * 3. Apply the mixin functionality to the construct (applyTo) - */ -export interface IMixin { - /** - * Determines whether this mixin can be applied to the given construct. - * - * This method should perform type checking and compatibility validation - * to ensure the mixin can safely operate on the construct. - * - * @param construct - The construct to check for compatibility - * @returns true if the mixin supports this construct type, false otherwise - */ - supports(construct: IConstruct): boolean; - - /** - * Validates the construct before applying the mixin. - * - * This optional method allows the mixin to perform additional validation - * beyond basic type compatibility. It can check for required properties, - * configuration constraints, or other preconditions. - * - * @param construct - The construct to validate - * @returns An array of validation error messages, or empty array if valid - */ - validate?(construct: IConstruct): string[]; - - /** - * Applies the mixin functionality to the target construct. - * - * This method performs the actual work of the mixin, such as: - * - Adding new properties or methods - * - Modifying existing behavior - * - Setting up additional resources or configurations - * - Establishing relationships with other constructs - * - * @param construct - The construct to apply the mixin to - * @returns The modified construct (may be the same instance or a wrapper) - */ - applyTo(construct: IConstruct): IConstruct; -} - -/** - * Abstract base class for mixins that provides default implementations - * and simplifies mixin creation. - */ -export abstract class Mixin implements IMixin { - /** - * Default implementation that supports any construct. - * Override this method to add type-specific support logic. - */ - public supports(_construct: IConstruct): boolean { - return true; - } - - /** - * Default validation implementation that returns no errors. - * Override this method to add custom validation logic. - */ - public validate(_construct: IConstruct): string[] { - return []; - } - - abstract applyTo(construct: IConstruct): IConstruct; -} +export * from './property-mixins'; diff --git a/packages/@aws-cdk/mixins-preview/lib/mixins/property-mixins.ts b/packages/@aws-cdk/mixins-preview/lib/mixins/property-mixins.ts new file mode 100644 index 0000000000000..53cfb157a0319 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/mixins/property-mixins.ts @@ -0,0 +1,25 @@ +/** + * Strategy for handling nested properties in L1 property mixins + */ +export enum PropertyMergeStrategy { + /** + * Override all properties + */ + OVERRIDE = 'override', + /** + * Deep merge nested objects, override primitives and arrays + */ + MERGE = 'merge', +} + +/** + * Options for applying CfnProperty mixins + */ +export interface CfnPropertyMixinOptions { + /** + * Strategy for merging nested properties + * + * @default - PropertyMergeStrategy.MERGE + */ + readonly strategy?: PropertyMergeStrategy; +} diff --git a/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/bucket.ts b/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/bucket.ts new file mode 100644 index 0000000000000..1be5f5c5e0a11 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/bucket.ts @@ -0,0 +1,96 @@ +import type { IConstruct } from 'constructs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { CfnResource, CustomResource, Tags } from 'aws-cdk-lib/core'; +import { AutoDeleteObjectsProvider } from '../../custom-resource-handlers/aws-s3/auto-delete-objects-provider'; +import type { IMixin } from '../../core'; + +const AUTO_DELETE_OBJECTS_RESOURCE_TYPE = 'Custom::S3AutoDeleteObjects'; +const AUTO_DELETE_OBJECTS_TAG = 'aws-cdk:auto-delete-objects'; + +/** + * S3-specific mixin for auto-deleting objects. + * @mixin true + */ +export class AutoDeleteObjects implements IMixin { + supports(construct: IConstruct): construct is s3.CfnBucket { + return CfnResource.isCfnResource(construct) && construct.cfnResourceType === s3.CfnBucket.CFN_RESOURCE_TYPE_NAME; + } + + applyTo(construct: IConstruct): IConstruct { + if (!this.supports(construct)) { + return construct; + } + + const ref = construct.bucketRef; + + const provider = AutoDeleteObjectsProvider.getOrCreateProvider(construct, AUTO_DELETE_OBJECTS_RESOURCE_TYPE, { + useCfnResponseWrapper: false, + description: `Lambda function for auto-deleting objects in ${ref.bucketName} S3 bucket.`, + }); + + // Get or create bucket policy + let policy = construct.node.tryFindChild('Policy') as s3.CfnBucketPolicy | undefined; + if (!policy) { + policy = new s3.CfnBucketPolicy(construct, 'Policy', { + bucket: ref.bucketName, + policyDocument: { + Statement: [], + }, + }); + } + + // Add policy statement to allow the custom resource to delete objects + const policyDoc = policy.policyDocument as any; + if (!policyDoc.Statement) { + policyDoc.Statement = []; + } + policyDoc.Statement.push({ + Effect: 'Allow', + Principal: { AWS: provider.roleArn }, + Action: [ + 's3:PutBucketPolicy', + 's3:GetBucket*', + 's3:List*', + 's3:DeleteObject*', + ], + Resource: [ + ref.bucketArn, + `${ref.bucketArn}/*`, + ], + }); + + const customResource = new CustomResource(construct, 'AutoDeleteObjectsCustomResource', { + resourceType: AUTO_DELETE_OBJECTS_RESOURCE_TYPE, + serviceToken: provider.serviceToken, + properties: { + BucketName: ref.bucketName, + }, + }); + + // Ensure bucket policy is deleted AFTER the custom resource + customResource.node.addDependency(policy); + + // Tag the bucket to record that we want it autodeleted + Tags.of(construct).add(AUTO_DELETE_OBJECTS_TAG, 'true'); + + return construct; + } +} + +/** + * S3-specific mixin for enabling versioning. + */ +export class EnableVersioning implements IMixin { + supports(construct: IConstruct): boolean { + return construct instanceof s3.CfnBucket; + } + + applyTo(construct: IConstruct): IConstruct { + if (construct instanceof s3.CfnBucket) { + construct.versioningConfiguration = { + status: 'Enabled', + }; + } + return construct; + } +} diff --git a/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/mixins.ts b/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/mixins.ts new file mode 100644 index 0000000000000..ef3c39946739b --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/services/aws-s3/mixins.ts @@ -0,0 +1 @@ +export * from './bucket'; diff --git a/packages/@aws-cdk/mixins-preview/lib/util/property-mixins.ts b/packages/@aws-cdk/mixins-preview/lib/util/property-mixins.ts new file mode 100644 index 0000000000000..2f25083fb4e50 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/util/property-mixins.ts @@ -0,0 +1,178 @@ +import { AssumptionError } from 'aws-cdk-lib/core'; + +/** + * Deep merge utility for nested objects + * @param target The target object to merge into + * @param source The source object to merge from + * @param mergeOnly The explicit list of property keys to copy from source + */ +export function deepMerge(target: any, source: any, mergeOnly: string[]): any { + for (const key of mergeOnly) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + + if (!(key in source)) { + continue; + } + + const sourceValue = source[key]; + const targetValue = target[key]; + + if (typeof sourceValue === 'object' && sourceValue != null && !Array.isArray(sourceValue) && + typeof targetValue === 'object' && targetValue != null && !Array.isArray(targetValue)) { + target[key] = deepMergeCopy(Object.create(null), targetValue, sourceValue); + } else { + target[key] = sourceValue; + } + } + + return target; +} + +/** + * Object keys that deepMerge should not consider. Currently these include + * CloudFormation intrinsics + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html + */ + +const MERGE_EXCLUDE_KEYS: string[] = [ + 'Ref', + 'Fn::Base64', + 'Fn::Cidr', + 'Fn::FindInMap', + 'Fn::GetAtt', + 'Fn::GetAZs', + 'Fn::ImportValue', + 'Fn::Join', + 'Fn::Select', + 'Fn::Split', + 'Fn::Sub', + 'Fn::Transform', + 'Fn::And', + 'Fn::Equals', + 'Fn::If', + 'Fn::Not', + 'Fn::Or', +]; + +/** + * This is an unchanged copy from packages/aws-cdk-lib/core/lib/cfn-resource.ts + * The intention will be to use this function from core once the package is merged. + * + * Merges `source` into `target`, overriding any existing values. + * `null`s will cause a value to be deleted. + */ +function deepMergeCopy(target: any, ...sources: any[]) { + for (const source of sources) { + if (typeof(source) !== 'object' || typeof(target) !== 'object') { + throw new AssumptionError(`Invalid usage. Both source (${JSON.stringify(source)}) and target (${JSON.stringify(target)}) must be objects`); + } + + for (const key of Object.keys(source)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + + 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] = {}; + + /** + * If we have something that looks like: + * + * target: { Type: 'MyResourceType', Properties: { prop1: { Ref: 'Param' } } } + * sources: [ { Properties: { prop1: [ 'Fn::Join': ['-', 'hello', 'world'] ] } } ] + * + * Eventually we will get to the point where we have + * + * target: { prop1: { Ref: 'Param' } } + * sources: [ { prop1: { 'Fn::Join': ['-', 'hello', 'world'] } } ] + * + * We need to recurse 1 more time, but if we do we will end up with + * { prop1: { Ref: 'Param', 'Fn::Join': ['-', 'hello', 'world'] } } + * which is not what we want. + * + * Instead we check to see whether the `target` value (i.e. target.prop1) + * is an object that contains a key that we don't want to recurse on. If it does + * then we essentially drop it and end up with: + * + * { prop1: { 'Fn::Join': ['-', 'hello', 'world'] } } + */ + } else if (Object.keys(target[key]).length === 1) { + if (MERGE_EXCLUDE_KEYS.includes(Object.keys(target[key])[0])) { + target[key] = {}; + } + } + + /** + * There might also be the case where the source is an intrinsic + * + * target: { + * Type: 'MyResourceType', + * Properties: { + * prop1: { subprop: { name: { 'Fn::GetAtt': 'abc' } } } + * } + * } + * sources: [ { + * Properties: { + * prop1: { subprop: { 'Fn::If': ['SomeCondition', {...}, {...}] }} + * } + * } ] + * + * We end up in a place that is the reverse of the above check, the source + * becomes an intrinsic before the target + * + * target: { subprop: { name: { 'Fn::GetAtt': 'abc' } } } + * sources: [{ + * 'Fn::If': [ 'MyCondition', {...}, {...} ] + * }] + */ + if (Object.keys(value).length === 1) { + if (MERGE_EXCLUDE_KEYS.includes(Object.keys(value)[0])) { + target[key] = {}; + } + } + + deepMergeCopy(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; +} + +/** + * Shallow assign utility that explicitly assigns each property + * @param target The target object to assign properties to + * @param source The source object to read properties from + * @param assignOnly The explicit list of property keys that are allowed to be assigned + */ +export function shallowAssign(target: any, source: any, assignOnly: string[]): any { + for (const key of assignOnly) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + + if (key in source) { + target[key] = source[key]; + } + } + return target; +} diff --git a/packages/@aws-cdk/mixins-preview/lib/with.ts b/packages/@aws-cdk/mixins-preview/lib/with.ts new file mode 100644 index 0000000000000..4bde8667fa659 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/with.ts @@ -0,0 +1,20 @@ +import type { IConstruct } from 'constructs'; +import { Construct } from 'constructs'; +import type { IMixin } from './core'; +import { Mixins, ConstructSelector } from './core'; + +declare module 'constructs' { + interface IConstruct { + with(mixin: IMixin): this; + } + + interface Construct { + with(mixin: IMixin): this; + } +} + +// Hack the prototype to add .with() method +(Construct.prototype as any).with = function(this: IConstruct, mixin: IMixin): IConstruct { + Mixins.of(this, ConstructSelector.cfnResource()).mustApply(mixin); + return this; +}; diff --git a/packages/@aws-cdk/mixins-preview/package.json b/packages/@aws-cdk/mixins-preview/package.json index 761f26973a4d6..e502bf263a69c 100644 --- a/packages/@aws-cdk/mixins-preview/package.json +++ b/packages/@aws-cdk/mixins-preview/package.json @@ -9,7 +9,7 @@ "./.jsii": "./.jsii", "./.warnings.jsii.js": "./.warnings.jsii.js", "./core": "./lib/core/index.js", - "./extensions": "./lib/extensions/index.js", + "./with": "./lib/with.js", "./mixins": "./lib/mixins/index.js" }, "jsii": { @@ -85,10 +85,12 @@ }, "license": "Apache-2.0", "devDependencies": { - "aws-cdk-lib": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/pkglint": "0.0.0", + "@aws-cdk/custom-resource-handlers": "0.0.0", "@types/jest": "^29.5.14", + "aws-cdk-lib": "0.0.0", + "constructs": "^10.0.0", "jest": "^29.7.0" }, "homepage": "https://github.com/aws/aws-cdk", @@ -105,7 +107,11 @@ "awscdkio": { "announce": false }, - "cdk-build": {}, + "cdk-build": { + "pre": [ + "./scripts/airlift-custom-resource-handlers.sh" + ] + }, "publishConfig": { "tag": "latest" } diff --git a/packages/@aws-cdk/mixins-preview/rosetta/default.ts-fixture b/packages/@aws-cdk/mixins-preview/rosetta/default.ts-fixture index e8deb6060d76d..537091739a2ef 100644 --- a/packages/@aws-cdk/mixins-preview/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/mixins-preview/rosetta/default.ts-fixture @@ -1,11 +1,25 @@ -// Fixture with packages imported, but nothing else import { Construct } from 'constructs'; -import { Stack } from '@aws-cdk/core'; +import { Stack, App } from 'aws-cdk-lib/core'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { + Mixins, + ConstructSelector, + IMixin +} from '@aws-cdk/mixins-preview/core'; +import { + EncryptionAtRest, + AutoDeleteObjects, + EnableVersioning, + CfnPropertiesMixin +} from '@aws-cdk/mixins-preview/mixins'; -class Fixture extends Stack { - constructor(scope: Construct, id: string) { - super(scope, id); +declare const scope: Construct; - /// here - } +class ProductionSecurityMixin implements IMixin { + supports(_construct: any): boolean { return true; } + applyTo(construct: any): any { return construct; } } + +/// here diff --git a/packages/@aws-cdk/mixins-preview/scripts/airlift-custom-resource-handlers.sh b/packages/@aws-cdk/mixins-preview/scripts/airlift-custom-resource-handlers.sh new file mode 100755 index 0000000000000..0a5dedb6893ba --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/scripts/airlift-custom-resource-handlers.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +scriptdir=$(cd $(dirname $0) && pwd) +targetdir=${scriptdir}/.. +customresourcedir=$(node -p "path.dirname(require.resolve('@aws-cdk/custom-resource-handlers/package.json'))") + +function airlift() { + mkdir -p $targetdir/lib/custom-resource-handlers/$1 + cp $customresourcedir/$2 $targetdir/lib/custom-resource-handlers/$1 +} + +function recurse() { + local dir=$1 + + for file in $dir/*; do + if [ -f $file ]; then + case $file in + $customresourcedir/dist/aws-s3/*/index.js) + cr=$(echo $file | rev | cut -d "/" -f 2-4 | rev) + airlift $cr $cr/index.js + ;; + esac + fi + + if [ -d $file ]; then + recurse $file + fi + done +} + +recurse $customresourcedir/dist diff --git a/packages/@aws-cdk/mixins-preview/test/core/mixins.test.ts b/packages/@aws-cdk/mixins-preview/test/core/mixins.test.ts new file mode 100644 index 0000000000000..2b479d3be1f05 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/core/mixins.test.ts @@ -0,0 +1,169 @@ +import { Construct } from 'constructs'; +import { Stack, App } from 'aws-cdk-lib/core'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import type { IMixin } from '../../lib/core'; +import { + Mixin, + Mixins, +} from '../../lib/core'; + +class TestConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + } +} + +class TestMixin extends Mixin { + applyTo(construct: any): any { + (construct as any).mixinApplied = true; + return construct; + } +} + +class SelectiveMixin implements IMixin { + supports(_construct: any): boolean { + return _construct instanceof s3.CfnBucket; + } + + applyTo(construct: any): any { + (construct as any).selectiveMixinApplied = true; + return construct; + } +} + +class ValidatingMixin implements IMixin { + supports(_construct: any): boolean { + return true; + } + + validate(construct: any): string[] { + if (!(construct as any).requiredProperty) { + return ['Missing required property']; + } + return []; + } + + applyTo(construct: any): any { + return construct; + } +} + +describe('Core Mixins Framework', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + describe('IMixin', () => { + test('mixin can be applied to supported construct', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new TestMixin(); + + expect(mixin.supports(construct)).toBe(true); + expect(mixin.validate(construct)).toEqual([]); + + const result = mixin.applyTo(construct); + expect((result as any).mixinApplied).toBe(true); + }); + + test('selective mixin only applies to supported constructs', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + const mixin = new SelectiveMixin(); + + expect(mixin.supports(bucket)).toBe(true); + expect(mixin.supports(logGroup)).toBe(false); + }); + + test('validation errors are detected', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new ValidatingMixin(); + + expect(mixin.validate(construct)).toEqual(['Missing required property']); + + (construct as any).requiredProperty = true; + expect(mixin.validate(construct)).toEqual([]); + }); + }); + + describe('Mixins.of()', () => { + test('applies mixin to single construct', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new TestMixin(); + + Mixins.of(construct).apply(mixin); + expect((construct as any).mixinApplied).toBe(true); + }); + + test('applies mixin to all constructs in scope', () => { + const construct1 = new TestConstruct(stack, 'test1'); + const construct2 = new TestConstruct(stack, 'test2'); + const mixin = new TestMixin(); + + Mixins.of(stack).apply(mixin); + expect((construct1 as any).mixinApplied).toBe(true); + expect((construct2 as any).mixinApplied).toBe(true); + }); + + test('skips unsupported constructs', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + const mixin = new SelectiveMixin(); + + Mixins.of(stack).apply(mixin); + expect((bucket as any).selectiveMixinApplied).toBe(true); + expect((logGroup as any).selectiveMixinApplied).toBeUndefined(); + }); + + test('mustApply throws when no constructs match', () => { + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + const mixin = new SelectiveMixin(); + + expect(() => { + Mixins.of(logGroup).mustApply(mixin); + }).toThrow(); + }); + + test('validation errors cause exceptions', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new ValidatingMixin(); + + expect(() => { + Mixins.of(construct).apply(mixin); + }).toThrow(); + }); + + test('mustApply succeeds when at least one construct matches', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + new logs.CfnLogGroup(stack, 'LogGroup'); + const mixin = new SelectiveMixin(); + + expect(() => { + Mixins.of(stack).mustApply(mixin); + }).not.toThrow(); + + expect((bucket as any).selectiveMixinApplied).toBe(true); + }); + }); + + describe('Mixin base class', () => { + test('provides default implementations', () => { + class SimpleMixin extends Mixin { + applyTo(construct: any): any { + return construct; + } + } + + const mixin = new SimpleMixin(); + const construct = new TestConstruct(stack, 'test'); + + expect(mixin.supports(construct)).toBe(true); + expect(mixin.validate(construct)).toEqual([]); + expect(mixin.applyTo(construct)).toBe(construct); + }); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/core/selectors.test.ts b/packages/@aws-cdk/mixins-preview/test/core/selectors.test.ts new file mode 100644 index 0000000000000..95b8721bc2a83 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/core/selectors.test.ts @@ -0,0 +1,69 @@ +import { Construct } from 'constructs'; +import { Stack, App } from 'aws-cdk-lib/core'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import { ConstructSelector } from '../../lib/core'; + +class TestConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + } +} + +describe('ConstructSelector', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + test('all() selects all constructs', () => { + const construct1 = new TestConstruct(stack, 'test1'); + const construct2 = new TestConstruct(stack, 'test2'); + + const selected = ConstructSelector.all().select(stack); + expect(selected).toContain(stack); + expect(selected).toContain(construct1); + expect(selected).toContain(construct2); + }); + + test('resourcesOfType() selects by type', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + + const selected = ConstructSelector.resourcesOfType(s3.CfnBucket).select(stack); + expect(selected).toContain(bucket); + expect(selected).not.toContain(logGroup); + }); + + test('resourcesOfType() selects by CloudFormation type', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + + const selected = ConstructSelector.resourcesOfType('AWS::S3::Bucket').select(stack); + expect(selected).toContain(bucket); + expect(selected).not.toContain(logGroup); + }); + + test('byId() selects by ID pattern', () => { + const prodBucket = new s3.CfnBucket(stack, 'prod-bucket'); + const devBucket = new s3.CfnBucket(stack, 'dev-bucket'); + + const selected = ConstructSelector.byId(/prod-.*/).select(stack); + expect(selected).toContain(prodBucket); + expect(selected).not.toContain(devBucket); + }); + + test('cfnResource() selects CfnResource or default child', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const l2Bucket = new s3.Bucket(stack, 'L2Bucket'); + + const selectedFromCfn = ConstructSelector.cfnResource().select(bucket); + expect(selectedFromCfn).toContain(bucket); + + const selectedFromL2 = ConstructSelector.cfnResource().select(l2Bucket); + expect(selectedFromL2.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/integration.test.ts b/packages/@aws-cdk/mixins-preview/test/integration.test.ts new file mode 100644 index 0000000000000..25fad4ae16e55 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/integration.test.ts @@ -0,0 +1,62 @@ +import { Stack, App } from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import { + Mixins, + ConstructSelector, +} from '../lib/core'; +import * as s3Mixins from '../lib/services/aws-s3/mixins'; + +describe('Integration Tests', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + test('selective application with multiple selectors', () => { + const prodBucket = new s3.CfnBucket(stack, 'ProdBucket'); + const devBucket = new s3.CfnBucket(stack, 'DevBucket'); + const logGroup = new logs.CfnLogGroup(stack, 'LogGroup'); + + // Apply encryption only to production buckets + Mixins.of( + stack, + ConstructSelector.byId(/.*Prod.*/), + ).apply(new s3Mixins.AutoDeleteObjects()); + + // Apply versioning to all S3 buckets + Mixins.of( + stack, + ConstructSelector.resourcesOfType(s3.CfnBucket), + ).apply(new s3Mixins.EnableVersioning()); + + // Verify auto-delete only applied to prod bucket + const template = Template.fromStack(stack); + const resources = template.findResources('Custom::S3AutoDeleteObjects'); + expect(Object.keys(resources).length).toBe(1); + expect(resources[Object.keys(resources)[0]].Properties.BucketName.Ref).toBe('ProdBucket'); + + // Verify versioning applied to both buckets + expect((prodBucket.versioningConfiguration as any)?.status).toBe('Enabled'); + expect((devBucket.versioningConfiguration as any)?.status).toBe('Enabled'); + expect((logGroup as any).versioningConfiguration).toBeUndefined(); + }); + + test('chained mixin application', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket1'); + + Mixins.of(bucket) + .apply(new s3Mixins.AutoDeleteObjects()) + .apply(new s3Mixins.EnableVersioning()); + + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: { Ref: 'Bucket1' }, + }); + expect((bucket.versioningConfiguration as any)?.status).toBe('Enabled'); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/mixin.test.ts b/packages/@aws-cdk/mixins-preview/test/mixin.test.ts index b9aa16527536c..d8622fa605bd6 100644 --- a/packages/@aws-cdk/mixins-preview/test/mixin.test.ts +++ b/packages/@aws-cdk/mixins-preview/test/mixin.test.ts @@ -1,5 +1,5 @@ import { Construct } from 'constructs'; -import { Mixin } from '../lib'; +import { Mixin } from '../lib/core'; class TestConstruct extends Construct { constructor(scope: Construct, id: string) { @@ -26,3 +26,18 @@ describe('IMixin', () => { expect((result as any).mixinApplied).toBe(true); }); }); + +describe('Mixin', () => { + test('isMixin returns true for Mixin instances', () => { + const mixin = new TestMixin(); + expect(Mixin.isMixin(mixin)).toBe(true); + }); + + test('isMixin returns false for non-Mixin objects', () => { + expect(Mixin.isMixin({})).toBe(false); + expect(Mixin.isMixin(null)).toBe(false); + expect(Mixin.isMixin(undefined)).toBe(false); + expect(Mixin.isMixin('string')).toBe(false); + expect(Mixin.isMixin(123)).toBe(false); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/mixins/property-mixins.test.ts b/packages/@aws-cdk/mixins-preview/test/mixins/property-mixins.test.ts new file mode 100644 index 0000000000000..ba34fb96530f0 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/mixins/property-mixins.test.ts @@ -0,0 +1,185 @@ +import { deepMerge, shallowAssign } from '../../lib/util/property-mixins'; + +describe('Property Mixins', () => { + describe('deepMerge', () => { + test('merges simple properties', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + deepMerge(target, source, ['b', 'c']); + + expect(target).toEqual({ a: 1, b: 3, c: 4 }); + }); + + test('merges nested objects', () => { + const target = { config: { x: 1, y: 2 } }; + const source = { config: { y: 3, z: 4 } }; + + deepMerge(target, source, ['config']); + + expect(target).toEqual({ config: { x: 1, y: 3, z: 4 } }); + }); + + test('only merges allowed keys', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 3 }); + }); + + test('handles missing keys in target', () => { + const target = { a: 1 }; + const source = { b: 2 }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 2 }); + }); + + test('handles missing keys in source', () => { + const target = { a: 1, b: 2 }; + const source = { c: 3 }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 2 }); + }); + + test('overrides arrays', () => { + const target = { items: [1, 2] }; + const source = { items: [3, 4] }; + + deepMerge(target, source, ['items']); + + expect(target).toEqual({ items: [3, 4] }); + }); + + test('handles deeply nested objects', () => { + const target = { a: { b: { c: 1, d: 2 } } }; + const source = { a: { b: { d: 3, e: 4 } } }; + + deepMerge(target, source, ['a']); + + expect(target).toEqual({ a: { b: { c: 1, d: 3, e: 4 } } }); + }); + + test('handles undefined values', () => { + const target = { a: 1, b: 2 }; + const source = { b: undefined }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: undefined }); + }); + + test('does not remove unspecified keys', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { b: 20 }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 20, c: 3 }); + }); + + test('does not copy keys not in allowedKeys', () => { + const target = { a: 1 }; + const source = { b: 2, c: 3 }; + + deepMerge(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 2 }); + expect('c' in target).toBe(false); + }); + + test('handles objects with circular references', () => { + const target: any = { a: 1, node: {} }; + target.node.parent = target; + const source = { b: 2 }; + + expect(() => deepMerge(target, source, ['b'])).not.toThrow(); + expect(target).toEqual({ a: 1, b: 2, node: { parent: target } }); + }); + + test('does not traverse circular references in target', () => { + const target: any = { prop1: 'value1', circular: {} }; + target.circular.ref = target; + target.circular.deep = { ref: target }; + const source = { prop1: 'updated', prop2: 'new' }; + + expect(() => deepMerge(target, source, ['prop1', 'prop2'])).not.toThrow(); + expect(target.prop1).toBe('updated'); + expect(target.prop2).toBe('new'); + expect(target.circular.ref).toBe(target); + }); + + test('prevents prototype pollution', () => { + const target = { a: 1 }; + const source = { b: 2 }; + + deepMerge(target, source, ['__proto__', 'constructor', 'prototype', 'b']); + + expect(target).toEqual({ a: 1, b: 2 }); + expect(Object.prototype).not.toHaveProperty('polluted'); + }); + }); + + describe('shallowAssign', () => { + test('assigns simple properties', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + shallowAssign(target, source, ['b', 'c']); + + expect(target).toEqual({ a: 1, b: 3, c: 4 }); + }); + + test('only assigns allowed keys', () => { + const target = { a: 1, b: 2 }; + const source = { b: 3, c: 4 }; + + shallowAssign(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 3 }); + }); + + test('does not deep merge nested objects', () => { + const target = { config: { x: 1, y: 2 } }; + const source = { config: { y: 3, z: 4 } }; + + shallowAssign(target, source, ['config']); + + expect(target).toEqual({ config: { y: 3, z: 4 } }); + }); + + test('handles missing keys in source', () => { + const target = { a: 1, b: 2 }; + const source = { c: 3 }; + + shallowAssign(target, source, ['b']); + + expect(target).toEqual({ a: 1, b: 2 }); + }); + + test('assigns arrays by reference', () => { + const target = { items: [1, 2] }; + const arr = [3, 4]; + const source = { items: arr }; + + shallowAssign(target, source, ['items']); + + expect(target.items).toBe(arr); + }); + + test('prevents prototype pollution', () => { + const target = { a: 1 }; + const source = { b: 2 }; + + shallowAssign(target, source, ['__proto__', 'constructor', 'prototype', 'b']); + + expect(target).toEqual({ a: 1, b: 2 }); + expect(Object.prototype).not.toHaveProperty('polluted'); + }); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/mixins/s3-mixins.test.ts b/packages/@aws-cdk/mixins-preview/test/mixins/s3-mixins.test.ts new file mode 100644 index 0000000000000..34f4aad097ebc --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/mixins/s3-mixins.test.ts @@ -0,0 +1,66 @@ +import { Construct } from 'constructs'; +import { Stack, App } from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as s3Mixins from '../../lib/services/aws-s3/mixins'; + +class TestConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + } +} + +describe('S3 Mixins', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + describe('AutoDeleteObjects', () => { + test('applies to S3 bucket', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const mixin = new s3Mixins.AutoDeleteObjects(); + + expect(mixin.supports(bucket)).toBe(true); + mixin.applyTo(bucket); + + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: { Ref: 'Bucket' }, + }); + template.hasResourceProperties('AWS::S3::BucketPolicy', { + Bucket: { Ref: 'Bucket' }, + }); + }); + + test('does not support non-S3 constructs', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new s3Mixins.AutoDeleteObjects(); + + expect(mixin.supports(construct)).toBe(false); + }); + }); + + describe('EnableVersioning', () => { + test('applies to S3 bucket', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const mixin = new s3Mixins.EnableVersioning(); + + expect(mixin.supports(bucket)).toBe(true); + mixin.applyTo(bucket); + + const versionConfig = bucket.versioningConfiguration as any; + expect(versionConfig?.status).toBe('Enabled'); + }); + + test('does not support non-S3 constructs', () => { + const construct = new TestConstruct(stack, 'test'); + const mixin = new s3Mixins.EnableVersioning(); + + expect(mixin.supports(construct)).toBe(false); + }); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/with.test.ts b/packages/@aws-cdk/mixins-preview/test/with.test.ts new file mode 100644 index 0000000000000..dd347ed46c74e --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/with.test.ts @@ -0,0 +1,47 @@ +import { Stack, App } from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as sns from 'aws-cdk-lib/aws-sns'; +import '../lib/with'; +import * as s3Mixins from '../lib/services/aws-s3/mixins'; + +describe('Mixin Extensions', () => { + let app: App; + let stack: Stack; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + describe('.with() method', () => { + test('can chain multiple mixins', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket') + .with(new s3Mixins.EnableVersioning()) + .with(new s3Mixins.AutoDeleteObjects()); + + const versionConfig = bucket.versioningConfiguration as any; + expect(versionConfig?.status).toBe('Enabled'); + + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: { Ref: 'Bucket' }, + }); + }); + + test('returns the same construct', () => { + const bucket = new s3.CfnBucket(stack, 'Bucket'); + const result = bucket.with(new s3Mixins.EnableVersioning()); + + expect(result).toBe(bucket); + }); + + test('throws when mixin does not support construct', () => { + const topic = new sns.Topic(stack, 'Topic'); + + expect(() => { + topic.with(new s3Mixins.EnableVersioning()); + }).toThrow(); + }); + }); +}); diff --git a/packages/aws-cdk-lib/aws-ec2/lib/cfn-init.ts b/packages/aws-cdk-lib/aws-ec2/lib/cfn-init.ts index f999e833cc8ab..a0d5707bfc397 100644 --- a/packages/aws-cdk-lib/aws-ec2/lib/cfn-init.ts +++ b/packages/aws-cdk-lib/aws-ec2/lib/cfn-init.ts @@ -302,7 +302,7 @@ function deepMerge(target?: Record, src?: Record) { if (src == null) { return target; } for (const [key, value] of Object.entries(src)) { - if (key === '__proto__' || key === 'constructor') { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } diff --git a/packages/aws-cdk-lib/core/lib/cfn-resource.ts b/packages/aws-cdk-lib/core/lib/cfn-resource.ts index 3a498039a9b19..c5267ccf2e3a0 100644 --- a/packages/aws-cdk-lib/core/lib/cfn-resource.ts +++ b/packages/aws-cdk-lib/core/lib/cfn-resource.ts @@ -661,7 +661,7 @@ function deepMerge(target: any, ...sources: any[]) { } for (const key of Object.keys(source)) { - if (key === '__proto__' || key === 'constructor') { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; }