Skip to content

Commit c588061

Browse files
authored
feat(spec2cdk): generate from<Resource>Arn and from<Resource><Prop> in every L1 (#35470)
For every L1 resource, generate a static factory method called `from<Resource>Arn()`: ```ts /** * Creates a new ITableRef from an ARN */ public static fromTableArn(scope: constructs.Construct, id: string, arn: string): ITableRef { class Import extends cdk.Resource { public tableRef: TableReference; /** * @param scope Scope in which this resource is defined * @param id Construct identifier for this resource (unique in its scope) */ public constructor(scope: constructs.Construct, id: string, arn: string) { super(scope, id, { "environmentFromArn": arn }); const variables = new cfn_parse.TemplateString("arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}").parse(arn); this.tableRef = { "tableName": variables.TableName, "tableArn": arn }; } } return new Import(scope, id, arn); } ``` as well as a `from<Prop>()` (where `<Prop>` is the single field in the primary identifier): ```ts /** * Creates a new ITableRef from a tableName */ public static fromTableName(scope: constructs.Construct, id: string, tableName: string): ITableRef { class Import extends cdk.Resource { public tableRef: TableReference; /** * @param scope Scope in which this resource is defined * @param id Construct identifier for this resource (unique in its scope) */ public constructor(scope: constructs.Construct, id: string, tableName: string) { const arn = new cfn_parse.TemplateString("arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}").interpolate({ "Partition": cdk.Stack.of(scope).partition, "Region": cdk.Stack.of(scope).region, "Account": cdk.Stack.of(scope).account, "TableName": tableName }); super(scope, id, { "environmentFromArn": arn }); this.tableRef = { "tableName": tableName, "tableArn": arn }; } } return new Import(scope, id, tableName); } ``` **Note**: If the primary identifier for a given resource type has more than one field, we skip the generation of the factory methods for the corresponding class. `TemplateStringParser` is a new class with two static methods (inverses of each other): - `parse`: matches a given ARN template with a concrete ARN string, and returns the values of each variable. - `interpolate`: given an ARN template and a map of variables, returns a string with the variables replaced with their respective values. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 31bf1bf commit c588061

File tree

12 files changed

+737
-35
lines changed

12 files changed

+737
-35
lines changed

packages/@aws-cdk/custom-resource-handlers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"@types/jest": "^29.5.14",
4949
"aws-sdk-client-mock": "4.1.0",
5050
"aws-sdk-client-mock-jest": "4.1.0",
51-
"@cdklabs/typewriter": "^0.0.5",
51+
"@cdklabs/typewriter": "^0.0.6",
5252
"jest": "^29.7.0",
5353
"sinon": "^9.2.4",
5454
"nock": "^13.5.6",

packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as iam from '../../aws-iam';
77
import * as kinesis from '../../aws-kinesis';
88
import * as kms from '../../aws-kms';
99
import * as s3 from '../../aws-s3';
10-
import { App, Aws, CfnDeletionPolicy, Duration, PhysicalName, RemovalPolicy, Resource, Stack, Tags } from '../../core';
10+
import { App, ArnFormat, Aws, CfnDeletionPolicy, Duration, PhysicalName, RemovalPolicy, Resource, Stack, Tags } from '../../core';
1111
import * as cr from '../../custom-resources';
1212
import * as cxapi from '../../cx-api';
1313
import {
@@ -397,6 +397,50 @@ describe('default properties', () => {
397397
});
398398
});
399399

400+
describe('L1 static factory methods', () => {
401+
test('fromTableArn', () => {
402+
const stack = new Stack();
403+
const table = CfnTable.fromTableArn(stack, 'MyBucket', 'arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable');
404+
expect(table.tableRef.tableName).toEqual('MyTable');
405+
expect(table.tableRef.tableArn).toEqual('arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable');
406+
407+
const env = stack.resolve((table as unknown as Resource).env);
408+
expect(env).toEqual({
409+
region: 'eu-west-1',
410+
account: '123456789012',
411+
});
412+
});
413+
414+
test('fromTableName', () => {
415+
const app = new App();
416+
const stack = new Stack(app, 'MyStack', {
417+
env: { account: '23432424', region: 'us-east-1' },
418+
});
419+
420+
const table = CfnTable.fromTableName(stack, 'Table', 'MyTable');
421+
const arnComponents = stack.splitArn(table.tableRef.tableArn, ArnFormat.SLASH_RESOURCE_NAME);
422+
423+
expect(table.tableRef.tableName).toEqual('MyTable');
424+
expect(arnComponents).toMatchObject({
425+
account: '23432424',
426+
region: 'us-east-1',
427+
resource: 'table',
428+
resourceName: 'MyTable',
429+
service: 'dynamodb',
430+
});
431+
432+
expect(stack.resolve(arnComponents.partition)).toEqual({
433+
Ref: 'AWS::Partition',
434+
});
435+
436+
const env = stack.resolve((table as unknown as Resource).env);
437+
expect(env).toEqual({
438+
region: 'us-east-1',
439+
account: '23432424',
440+
});
441+
});
442+
});
443+
400444
testDeprecated('when specifying every property', () => {
401445
const stack = new Stack();
402446
const stream = new kinesis.Stream(stack, 'MyStream');

packages/aws-cdk-lib/core/lib/helpers-internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { md5hash } from '../private/md5';
44
export * from './customize-roles';
55
export * from './string-specializer';
66
export * from './validate-all-props';
7+
export * from './strings';
78
export { constructInfoFromConstruct, constructInfoFromStack } from '../private/runtime-info';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { UnscopedValidationError } from '../errors';
2+
3+
/**
4+
* A string with variables in the form `${name}`.
5+
*/
6+
export class TemplateString {
7+
constructor(private readonly template: string) {
8+
}
9+
10+
/**
11+
* Parses a template string with variables in the form of `${var}` and extracts the values from the input string.
12+
* Returns a record mapping variable names to their corresponding values.
13+
* @param input the input string to parse
14+
* @throws UnscopedValidationError if the input does not match the template
15+
*/
16+
public parse(input: string): Record<string, string> {
17+
const templateParts = this.template.split(/(\$\{[^{}]+})/);
18+
const result: Record<string, string> = {};
19+
20+
let inputIndex = 0;
21+
22+
for (let i = 0; i < templateParts.length; i++) {
23+
const part = templateParts[i];
24+
if (part.startsWith('${') && part.endsWith('}')) {
25+
const varName = part.slice(2, -1);
26+
const nextLiteral = templateParts[i + 1] || '';
27+
28+
let value = '';
29+
if (nextLiteral) {
30+
const endIndex = input.indexOf(nextLiteral, inputIndex);
31+
if (endIndex === -1) {
32+
throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`);
33+
}
34+
value = input.slice(inputIndex, endIndex);
35+
inputIndex = endIndex;
36+
} else {
37+
value = input.slice(inputIndex);
38+
inputIndex = input.length;
39+
}
40+
41+
result[varName] = value;
42+
} else {
43+
if (input.slice(inputIndex, inputIndex + part.length) !== part) {
44+
throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`);
45+
}
46+
inputIndex += part.length;
47+
}
48+
}
49+
50+
if (inputIndex !== input.length) {
51+
throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`);
52+
}
53+
54+
return result;
55+
}
56+
57+
/**
58+
* Returns the template interpolated with the attributes of an object passed as input.
59+
* Attributes that don't match any variable in the template are ignored, but all template
60+
* variables must be replaced.
61+
* @param variables an object where keys are the variable names, and values are the values to be replaced.
62+
*/
63+
public interpolate(variables: Record<string, string>): string {
64+
return this.template.replace(/\${([^{}]+)}/g, (_, varName) => {
65+
if (variables[varName] === undefined) {
66+
throw new UnscopedValidationError(`Variable ${varName} not provided for template interpolation`);
67+
}
68+
return variables[varName];
69+
});
70+
}
71+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { UnscopedValidationError } from '../../lib';
2+
import { TemplateString } from '../../lib/helpers-internal';
3+
4+
describe('new TemplateString', () => {
5+
describe('parse', () => {
6+
it('parses template with single variable correctly', () => {
7+
const result = new TemplateString('Hello, ${name}!').parse('Hello, John!');
8+
expect(result).toEqual({ name: 'John' });
9+
});
10+
11+
it('parses template with multiple variables correctly', () => {
12+
const result = new TemplateString('My name is ${firstName} ${lastName}.').parse('My name is Jane Doe.');
13+
expect(result).toEqual({ firstName: 'Jane', lastName: 'Doe' });
14+
});
15+
16+
it('throws error when input does not match template', () => {
17+
expect(() => {
18+
new TemplateString('Hello, ${name}!').parse('Hi, John!');
19+
}).toThrow(UnscopedValidationError);
20+
});
21+
22+
it('parses template with no variables correctly', () => {
23+
const result = new TemplateString('Hello, world!').parse('Hello, world!');
24+
expect(result).toEqual({});
25+
});
26+
27+
it('parses template with trailing variable correctly', () => {
28+
const result = new TemplateString('Path: ${path}').parse('Path: /home/user');
29+
expect(result).toEqual({ path: '/home/user' });
30+
});
31+
32+
it('throws error when input has extra characters', () => {
33+
expect(() => {
34+
new TemplateString('Hello, ${name}!').parse('Hello, John!!');
35+
}).toThrow(UnscopedValidationError);
36+
});
37+
38+
it('parses template with adjacent variables correctly', () => {
39+
const result = new TemplateString('${greeting}, ${name}!').parse('Hi, John!');
40+
expect(result).toEqual({ greeting: 'Hi', name: 'John' });
41+
});
42+
43+
it('throws error when input is shorter than template', () => {
44+
expect(() => {
45+
new TemplateString('Hello, ${name}!').parse('Hello, ');
46+
}).toThrow(UnscopedValidationError);
47+
});
48+
49+
it('parses template with empty variable value correctly', () => {
50+
const result = new TemplateString('Hello, ${name}!').parse('Hello, !');
51+
expect(result).toEqual({ name: '' });
52+
});
53+
54+
it('parses template with variable at the start correctly', () => {
55+
const result = new TemplateString('${greeting}, world!').parse('Hi, world!');
56+
expect(result).toEqual({ greeting: 'Hi' });
57+
});
58+
59+
it('parses complex template correctly', () => {
60+
const result = new TemplateString('arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}')
61+
.parse('arn:aws:dynamodb:us-east-1:12345:table/MyTable');
62+
expect(result).toEqual({
63+
Partition: 'aws',
64+
Region: 'us-east-1',
65+
Account: '12345',
66+
TableName: 'MyTable',
67+
});
68+
});
69+
});
70+
71+
describe('interpolate', () => {
72+
it('interpolates template with single variable correctly', () => {
73+
const result = new TemplateString('Hello, ${name}!').interpolate({ name: 'John' });
74+
expect(result).toBe('Hello, John!');
75+
});
76+
77+
it('interpolates template with multiple variables correctly', () => {
78+
const result = new TemplateString('My name is ${firstName} ${lastName}.').interpolate({
79+
firstName: 'Jane',
80+
lastName: 'Doe',
81+
});
82+
expect(result).toBe('My name is Jane Doe.');
83+
});
84+
85+
it('throws error when variable is missing in interpolation', () => {
86+
expect(() => {
87+
new TemplateString('Hello, ${name}!').interpolate({});
88+
}).toThrow(UnscopedValidationError);
89+
});
90+
91+
it('interpolates template with no variables correctly', () => {
92+
const result = new TemplateString('Hello, world!').interpolate({});
93+
expect(result).toBe('Hello, world!');
94+
});
95+
96+
it('throws error when template contains undefined variable', () => {
97+
expect(() => {
98+
new TemplateString('Hello, ${name}!').interpolate({ greeting: 'Hi' });
99+
}).toThrow(UnscopedValidationError);
100+
});
101+
102+
it('interpolates template with adjacent variables correctly', () => {
103+
const result = new TemplateString('${greeting}, ${name}!').interpolate({ greeting: 'Hi', name: 'John' });
104+
expect(result).toBe('Hi, John!');
105+
});
106+
107+
it('interpolates template with empty variable value correctly', () => {
108+
const result = new TemplateString('Hello, ${name}!').interpolate({ name: '' });
109+
expect(result).toBe('Hello, !');
110+
});
111+
112+
it('interpolates template with variable at the start correctly', () => {
113+
const result = new TemplateString('${greeting}, world!').interpolate({ greeting: 'Hi' });
114+
expect(result).toBe('Hi, world!');
115+
});
116+
});
117+
});

tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class CdkCore extends ExternalModule {
4141
public readonly ITaggable = Type.fromName(this, 'ITaggable');
4242
public readonly ITaggableV2 = Type.fromName(this, 'ITaggableV2');
4343
public readonly IResolvable = Type.fromName(this, 'IResolvable');
44+
public readonly Stack = Type.fromName(this, 'Stack');
4445

4546
public readonly objectToCloudFormation = makeCallableExpr(this, 'objectToCloudFormation');
4647
public readonly stringToCloudFormation = makeCallableExpr(this, 'stringToCloudFormation');
@@ -93,6 +94,7 @@ export class CdkInternalHelpers extends ExternalModule {
9394
public readonly FromCloudFormationResult = $T(Type.fromName(this, 'FromCloudFormationResult'));
9495
public readonly FromCloudFormation = $T(Type.fromName(this, 'FromCloudFormation'));
9596
public readonly FromCloudFormationPropertyObject = Type.fromName(this, 'FromCloudFormationPropertyObject');
97+
public readonly TemplateString = Type.fromName(this, 'TemplateString');
9698

9799
constructor(parent: CdkCore) {
98100
super(`${parent.fqn}/core/lib/helpers-internal`);

0 commit comments

Comments
 (0)