Skip to content

Commit 56f22aa

Browse files
authored
chore(mixins-preview): implement Mixins RFC (#36013)
### Reason for this change This PR implements the foundational infrastructure for the CDK Mixins framework, introducing a composable abstraction system for applying functionality to CDK constructs. It is based on the _current_ state of the [RFC](aws/aws-cdk-rfcs#824). While the RFC is not yet approved and finalized, this PR aims to implement it including all its flaws so we can move forward with other implementing depending on this. We will update the package as the RFC evolves. ### Description of changes **Core Framework:** - Implemented `IMixin` interface and `Mixin` base class for creating composable abstractions - Added `Mixins.of()` API for applying mixins to constructs with `apply()` and `mustApply()` methods - Created `ConstructSelector` for filtering constructs by type, ID pattern, or CloudFormation resource type - Added comprehensive error handling and validation support - Added `.with()` augmentation to constructs for fluent mixin application **Testing:** - Comprehensive unit tests for core framework, selectors, and all built-in mixins - Integration tests demonstrating real-world usage patterns - Property manipulation utility tests including edge cases **Documentation:** - Updated README with usage examples, API reference, and best practices - Added Rosetta fixture for documentation code examples ### Description of how you validated changes - All new code is covered by unit tests - Integration tests validate end-to-end functionality - Rosetta fixture ensures documentation examples are valid ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) --- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 172c65f commit 56f22aa

File tree

25 files changed

+1282
-87
lines changed

25 files changed

+1282
-87
lines changed

packages/@aws-cdk/mixins-preview/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ Mixins.of(bucket)
3737
.apply(new AutoDeleteObjects());
3838
```
3939

40+
### Fluent Syntax with `.with()`
41+
42+
For convenience, you can use the `.with()` method for a more fluent syntax:
43+
44+
```typescript
45+
import '@aws-cdk/mixins-preview/with';
46+
47+
const bucket = new s3.CfnBucket(scope, "MyBucket")
48+
.with(new EnableVersioning())
49+
.with(new AutoDeleteObjects());
50+
```
51+
52+
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.
53+
54+
> **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.
55+
4056
## Creating Custom Mixins
4157

4258
Mixins are simple classes that implement the `IMixin` interface:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { IConstruct } from 'constructs';
2+
import { ValidationError } from 'aws-cdk-lib/core';
3+
import type { IMixin } from './mixins';
4+
import { ConstructSelector } from './selectors';
5+
6+
/**
7+
* Applies mixins to constructs.
8+
*/
9+
export class MixinApplicator {
10+
private readonly scope: IConstruct;
11+
private readonly selector: ConstructSelector;
12+
13+
constructor(
14+
scope: IConstruct,
15+
selector: ConstructSelector = ConstructSelector.all(),
16+
) {
17+
this.scope = scope;
18+
this.selector = selector;
19+
}
20+
21+
/**
22+
* Applies a mixin to selected constructs.
23+
*/
24+
apply(mixin: IMixin): this {
25+
const constructs = this.selector.select(this.scope);
26+
for (const construct of constructs) {
27+
if (mixin.supports(construct)) {
28+
const errors = mixin.validate?.(construct) ?? [];
29+
if (errors.length > 0) {
30+
throw new ValidationError(`Mixin validation failed: ${errors.join(', ')}`, this.scope);
31+
}
32+
mixin.applyTo(construct);
33+
}
34+
}
35+
return this;
36+
}
37+
38+
/**
39+
* Applies a mixin and requires that it be applied to at least one construct.
40+
*/
41+
mustApply(mixin: IMixin): this {
42+
const constructs = this.selector.select(this.scope);
43+
let applied = false;
44+
for (const construct of constructs) {
45+
if (mixin.supports(construct)) {
46+
const errors = mixin.validate?.(construct) ?? [];
47+
if (errors.length > 0) {
48+
throw new ValidationError(`Mixin validation failed: ${errors.join(', ')}`, construct);
49+
}
50+
mixin.applyTo(construct);
51+
applied = true;
52+
}
53+
}
54+
if (!applied) {
55+
throw new ValidationError(`Mixin ${mixin.constructor.name} could not be applied to any constructs`, this.scope);
56+
}
57+
return this;
58+
}
59+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Re-export all core functionality from separate modules
2+
export * from './mixins';
3+
export * from './selectors';
4+
export * from './applicator';
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { IConstruct } from 'constructs';
2+
import type { ConstructSelector } from './selectors';
3+
import { MixinApplicator } from './applicator';
4+
5+
// this will change when we update the interface to deliberately break compatibility checks
6+
const MIXIN_SYMBOL = Symbol.for('@aws-cdk/mixins-preview.Mixin.pre1');
7+
8+
/**
9+
* Main entry point for applying mixins.
10+
*/
11+
export class Mixins {
12+
/**
13+
* Creates a MixinApplicator for the given scope.
14+
*/
15+
static of(scope: IConstruct, selector?: ConstructSelector): MixinApplicator {
16+
return new MixinApplicator(scope, selector);
17+
}
18+
}
19+
20+
/**
21+
* A mixin is a reusable piece of functionality that can be applied to constructs
22+
* to add behavior, properties, or modify existing functionality without inheritance.
23+
*/
24+
export interface IMixin {
25+
/**
26+
* Determines whether this mixin can be applied to the given construct.
27+
*/
28+
supports(construct: IConstruct): boolean;
29+
30+
/**
31+
* Validates the construct before applying the mixin.
32+
*/
33+
validate?(construct: IConstruct): string[];
34+
35+
/**
36+
* Applies the mixin functionality to the target construct.
37+
*/
38+
applyTo(construct: IConstruct): IConstruct;
39+
}
40+
41+
/**
42+
* Abstract base class for mixins that provides default implementations.
43+
*/
44+
export abstract class Mixin implements IMixin {
45+
/**
46+
* Checks if `x` is a Mixin.
47+
*
48+
* @param x Any object
49+
* @returns true if `x` is an object created from a class which extends `Mixin`.
50+
*/
51+
static isMixin(x: any): x is Mixin {
52+
return x != null && typeof x === 'object' && MIXIN_SYMBOL in x;
53+
}
54+
55+
constructor() {
56+
Object.defineProperty(this, MIXIN_SYMBOL, { value: true });
57+
}
58+
59+
public supports(_construct: IConstruct): boolean {
60+
return true;
61+
}
62+
63+
public validate(_construct: IConstruct): string[] {
64+
return [];
65+
}
66+
67+
abstract applyTo(construct: IConstruct): IConstruct;
68+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { IConstruct } from 'constructs';
2+
import { CfnResource } from 'aws-cdk-lib/core';
3+
4+
/**
5+
* Selects constructs from a construct tree based on various criteria.
6+
*/
7+
export abstract class ConstructSelector {
8+
/**
9+
* Selects all constructs in the tree.
10+
*/
11+
static all(): ConstructSelector {
12+
return new AllConstructsSelector();
13+
}
14+
15+
/**
16+
* Selects CfnResource constructs or the default CfnResource child.
17+
*/
18+
static cfnResource(): ConstructSelector {
19+
return new CfnResourceSelector();
20+
}
21+
22+
/**
23+
* Selects constructs of a specific type.
24+
*/
25+
static resourcesOfType(type: string | any): ConstructSelector {
26+
return new ResourceTypeSelector(type);
27+
}
28+
29+
/**
30+
* Selects constructs whose IDs match a pattern.
31+
*/
32+
static byId(pattern: any): ConstructSelector {
33+
return new IdPatternSelector(pattern);
34+
}
35+
36+
/**
37+
* Selects constructs from the given scope based on the selector's criteria.
38+
*/
39+
abstract select(scope: IConstruct): IConstruct[];
40+
}
41+
42+
class AllConstructsSelector extends ConstructSelector {
43+
select(scope: IConstruct): IConstruct[] {
44+
return scope.node.findAll();
45+
}
46+
}
47+
48+
class CfnResourceSelector extends ConstructSelector {
49+
select(scope: IConstruct): IConstruct[] {
50+
if (CfnResource.isCfnResource(scope)) {
51+
return [scope];
52+
}
53+
const defaultChild = scope.node.defaultChild;
54+
if (CfnResource.isCfnResource(defaultChild)) {
55+
return [defaultChild];
56+
}
57+
return [];
58+
}
59+
}
60+
61+
class ResourceTypeSelector extends ConstructSelector {
62+
constructor(private readonly type: string | any) {
63+
super();
64+
}
65+
66+
select(scope: IConstruct): IConstruct[] {
67+
const result: IConstruct[] = [];
68+
const visit = (node: IConstruct) => {
69+
if (typeof this.type === 'string') {
70+
if (CfnResource.isCfnResource(node) && node.cfnResourceType === this.type) {
71+
result.push(node);
72+
}
73+
} else if ('isCfnResource' in this.type && 'CFN_RESOURCE_TYPE_NAME' in this.type) {
74+
if (CfnResource.isCfnResource(node) && node.cfnResourceType === this.type.CFN_RESOURCE_TYPE_NAME) {
75+
result.push(node);
76+
}
77+
} else {
78+
if (node instanceof this.type) {
79+
result.push(node);
80+
}
81+
}
82+
for (const child of node.node.children) {
83+
visit(child);
84+
}
85+
};
86+
visit(scope);
87+
return result;
88+
}
89+
}
90+
91+
class IdPatternSelector extends ConstructSelector {
92+
constructor(private readonly pattern: any) {
93+
super();
94+
}
95+
96+
select(scope: IConstruct): IConstruct[] {
97+
const result: IConstruct[] = [];
98+
const visit = (node: IConstruct) => {
99+
if (this.pattern && typeof this.pattern.test === 'function' && this.pattern.test(node.node.id)) {
100+
result.push(node);
101+
}
102+
for (const child of node.node.children) {
103+
visit(child);
104+
}
105+
};
106+
visit(scope);
107+
return result;
108+
}
109+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as path from 'path';
2+
import type { Construct } from 'constructs';
3+
import type { CustomResourceProviderOptions } from 'aws-cdk-lib/core';
4+
import { Stack, CustomResourceProviderBase, determineLatestNodeRuntimeName } from 'aws-cdk-lib/core';
5+
6+
export class AutoDeleteObjectsProvider extends CustomResourceProviderBase {
7+
public static getOrCreate(scope: Construct, uniqueid: string, props?: CustomResourceProviderOptions): string {
8+
return this.getOrCreateProvider(scope, uniqueid, props).serviceToken;
9+
}
10+
11+
public static getOrCreateProvider(scope: Construct, uniqueid: string, props?: CustomResourceProviderOptions): AutoDeleteObjectsProvider {
12+
const id = `${uniqueid}CustomResourceProvider`;
13+
const stack = Stack.of(scope);
14+
const existing = stack.node.tryFindChild(id) as AutoDeleteObjectsProvider;
15+
return existing ?? new AutoDeleteObjectsProvider(stack, id, props);
16+
}
17+
18+
private constructor(scope: Construct, id: string, props?: CustomResourceProviderOptions) {
19+
super(scope, id, {
20+
...props,
21+
codeDirectory: path.join(__dirname, '..', 'dist', 'aws-s3', 'auto-delete-objects-handler'),
22+
runtimeName: determineLatestNodeRuntimeName(scope),
23+
});
24+
this.node.addMetadata('aws:cdk:is-custom-resource-handler-customResourceProvider', true);
25+
}
26+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from './mixins';
1+
export * as core from './core';
2+
export * as mixins from './mixins';
Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1 @@
1-
import type { IConstruct } from 'constructs';
2-
3-
/**
4-
* A mixin is a reusable piece of functionality that can be applied to constructs
5-
* to add behavior, properties, or modify existing functionality without inheritance.
6-
*
7-
* Mixins follow a three-phase pattern:
8-
* 1. Check if the mixin supports the target construct (supports)
9-
* 2. Optionally validate the construct before applying (validate)
10-
* 3. Apply the mixin functionality to the construct (applyTo)
11-
*/
12-
export interface IMixin {
13-
/**
14-
* Determines whether this mixin can be applied to the given construct.
15-
*
16-
* This method should perform type checking and compatibility validation
17-
* to ensure the mixin can safely operate on the construct.
18-
*
19-
* @param construct - The construct to check for compatibility
20-
* @returns true if the mixin supports this construct type, false otherwise
21-
*/
22-
supports(construct: IConstruct): boolean;
23-
24-
/**
25-
* Validates the construct before applying the mixin.
26-
*
27-
* This optional method allows the mixin to perform additional validation
28-
* beyond basic type compatibility. It can check for required properties,
29-
* configuration constraints, or other preconditions.
30-
*
31-
* @param construct - The construct to validate
32-
* @returns An array of validation error messages, or empty array if valid
33-
*/
34-
validate?(construct: IConstruct): string[];
35-
36-
/**
37-
* Applies the mixin functionality to the target construct.
38-
*
39-
* This method performs the actual work of the mixin, such as:
40-
* - Adding new properties or methods
41-
* - Modifying existing behavior
42-
* - Setting up additional resources or configurations
43-
* - Establishing relationships with other constructs
44-
*
45-
* @param construct - The construct to apply the mixin to
46-
* @returns The modified construct (may be the same instance or a wrapper)
47-
*/
48-
applyTo(construct: IConstruct): IConstruct;
49-
}
50-
51-
/**
52-
* Abstract base class for mixins that provides default implementations
53-
* and simplifies mixin creation.
54-
*/
55-
export abstract class Mixin implements IMixin {
56-
/**
57-
* Default implementation that supports any construct.
58-
* Override this method to add type-specific support logic.
59-
*/
60-
public supports(_construct: IConstruct): boolean {
61-
return true;
62-
}
63-
64-
/**
65-
* Default validation implementation that returns no errors.
66-
* Override this method to add custom validation logic.
67-
*/
68-
public validate(_construct: IConstruct): string[] {
69-
return [];
70-
}
71-
72-
abstract applyTo(construct: IConstruct): IConstruct;
73-
}
1+
export * from './property-mixins';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Strategy for handling nested properties in L1 property mixins
3+
*/
4+
export enum PropertyMergeStrategy {
5+
/**
6+
* Override all properties
7+
*/
8+
OVERRIDE = 'override',
9+
/**
10+
* Deep merge nested objects, override primitives and arrays
11+
*/
12+
MERGE = 'merge',
13+
}
14+
15+
/**
16+
* Options for applying CfnProperty mixins
17+
*/
18+
export interface CfnPropertyMixinOptions {
19+
/**
20+
* Strategy for merging nested properties
21+
*
22+
* @default - PropertyMergeStrategy.MERGE
23+
*/
24+
readonly strategy?: PropertyMergeStrategy;
25+
}

0 commit comments

Comments
 (0)