From bcc7b81643d7c3c46adac3f01b666612e9090f1f Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:00:33 +0100 Subject: [PATCH 01/10] feat(spec2cdk): generate `fromArn` in every L1 --- .../aws-cdk-lib/aws-s3/test/bucket.test.ts | 9 + .../core/lib/helpers-internal/index.ts | 1 + .../core/lib/helpers-internal/strings.ts | 54 +++++ .../test/helpers-internal/strings.test.ts | 57 +++++ tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts | 1 + .../spec2cdk/lib/cdk/resource-class.ts | 142 +++++++++++-- .../test/__snapshots__/resources.test.ts.snap | 194 ++++++++++++++++++ .../@aws-cdk/spec2cdk/test/resources.test.ts | 30 +++ 8 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts create mode 100644 packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts diff --git a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts index f15beef2085e3..8253a504ddbfb 100644 --- a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts +++ b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts @@ -5222,6 +5222,15 @@ describe('bucket', () => { ); }); }); + + describe('L1 static factory methods', () => { + test('fromBucketArn', () => { + const stack = new cdk.Stack(); + const bucket = s3.Bucket.fromBucketArn(stack, 'MyBucket', 'arn:aws:s3:::my-bucket-name'); + expect(bucket.bucketRef.bucketName).toEqual('my-bucket-name'); + expect(bucket.bucketRef.bucketArn).toEqual('arn:aws:s3:::my-bucket-name'); + }); + }); }); class AccessBucketInjector implements cdk.IPropertyInjector { diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts index 5d85b9399403d..71f66956afb23 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/index.ts @@ -4,4 +4,5 @@ export { md5hash } from '../private/md5'; export * from './customize-roles'; export * from './string-specializer'; export * from './validate-all-props'; +export * from './strings'; export { constructInfoFromConstruct, constructInfoFromStack } from '../private/runtime-info'; diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts new file mode 100644 index 0000000000000..76ce37a569f37 --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts @@ -0,0 +1,54 @@ +import { UnscopedValidationError } from '../errors'; + +/** + * Utility class for parsing template strings with variables. + */ +export class TemplateStringParser { + /** + * Parses a template string with variables in the form of `${var}` and extracts the values from the input string. + * Returns a record mapping variable names to their corresponding values. + * @param template the template string containing variables + * @param input the input string to parse + * @throws UnscopedValidationError if the input does not match the template + */ + public static parse(template: string, input: string): Record { + const templateParts = template.split(/(\$\{[^}]+\})/); + const result: Record = {}; + + let inputIndex = 0; + + for (let i = 0; i < templateParts.length; i++) { + const part = templateParts[i]; + if (part.startsWith('${') && part.endsWith('}')) { + const varName = part.slice(2, -1); + const nextLiteral = templateParts[i + 1] || ''; + + let value = ''; + if (nextLiteral) { + const endIndex = input.indexOf(nextLiteral, inputIndex); + if (endIndex === -1) { + throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + } + value = input.slice(inputIndex, endIndex); + inputIndex = endIndex; + } else { + value = input.slice(inputIndex); + inputIndex = input.length; + } + + result[varName] = value; + } else { + if (input.slice(inputIndex, inputIndex + part.length) !== part) { + throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + } + inputIndex += part.length; + } + } + + if (inputIndex !== input.length) { + throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + } + + return result; + } +} diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts new file mode 100644 index 0000000000000..58199a25ef93d --- /dev/null +++ b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts @@ -0,0 +1,57 @@ +import { UnscopedValidationError } from '../../lib'; +import { TemplateStringParser } from '../../lib/helpers-internal/strings'; + +describe('TemplateStringParser', () => { + it('parses template with single variable correctly', () => { + const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!'); + expect(result).toEqual({ name: 'John' }); + }); + + it('parses template with multiple variables correctly', () => { + const result = TemplateStringParser.parse('My name is ${firstName} ${lastName}.', 'My name is Jane Doe.'); + expect(result).toEqual({ firstName: 'Jane', lastName: 'Doe' }); + }); + + it('throws error when input does not match template', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hi, John!'); + }).toThrow(UnscopedValidationError); + }); + + it('parses template with no variables correctly', () => { + const result = TemplateStringParser.parse('Hello, world!', 'Hello, world!'); + expect(result).toEqual({}); + }); + + it('parses template with trailing variable correctly', () => { + const result = TemplateStringParser.parse('Path: ${path}', 'Path: /home/user'); + expect(result).toEqual({ path: '/home/user' }); + }); + + it('throws error when input has extra characters', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!!'); + }).toThrow(UnscopedValidationError); + }); + + it('parses template with adjacent variables correctly', () => { + const result = TemplateStringParser.parse('${greeting}, ${name}!', 'Hi, John!'); + expect(result).toEqual({ greeting: 'Hi', name: 'John' }); + }); + + it('throws error when input is shorter than template', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hello, '); + }).toThrow(UnscopedValidationError); + }); + + it('parses template with empty variable value correctly', () => { + const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, !'); + expect(result).toEqual({ name: '' }); + }); + + it('parses template with variable at the start correctly', () => { + const result = TemplateStringParser.parse('${greeting}, world!', 'Hi, world!'); + expect(result).toEqual({ greeting: 'Hi' }); + }); +}); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts index e1477cacd98cd..fd5b72c7398a2 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts @@ -93,6 +93,7 @@ export class CdkInternalHelpers extends ExternalModule { public readonly FromCloudFormationResult = $T(Type.fromName(this, 'FromCloudFormationResult')); public readonly FromCloudFormation = $T(Type.fromName(this, 'FromCloudFormation')); public readonly FromCloudFormationPropertyObject = Type.fromName(this, 'FromCloudFormationPropertyObject'); + public readonly TemplateStringParser = Type.fromName(this, 'TemplateStringParser'); constructor(parent: CdkCore) { super(`${parent.fqn}/core/lib/helpers-internal`); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts index 68c275885ef26..ce5afc51cf0e2 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts @@ -2,38 +2,38 @@ import { PropertyType, Resource, SpecDatabase } from '@aws-cdk/service-spec-type import { $E, $T, + AnonymousInterfaceImplementation, Block, ClassType, code, - expr, - MemberVisibility, + expr, Expression, + Initializer, + InterfaceType, IScope, + IsNotNullish, + Lambda, + MemberVisibility, + Module, + ObjectLiteral, + Stability, stmt, StructType, SuperInitializer, TruthyOr, Type, - Initializer, - IsNotNullish, - AnonymousInterfaceImplementation, - Lambda, - Stability, - ObjectLiteral, - Module, - InterfaceType, } from '@cdklabs/typewriter'; import { CDK_CORE, CONSTRUCTS } from './cdk'; import { CloudFormationMapping } from './cloudformation-mapping'; import { ResourceDecider, shouldBuildReferenceInterface } from './resource-decider'; import { TypeConverter } from './type-converter'; import { - classNameFromResource, - cloudFormationDocLink, cfnParserNameFromType, - staticResourceTypeName, cfnProducerNameFromType, + classNameFromResource, + cloudFormationDocLink, propertyNameFromCloudFormation, propStructNameFromResource, staticRequiredTransform, + staticResourceTypeName, } from '../naming'; import { splitDocumentation } from '../util'; @@ -50,6 +50,7 @@ export class ResourceClass extends ClassType { private readonly decider: ResourceDecider; private readonly converter: TypeConverter; private readonly module: Module; + private referenceStruct?: StructType; constructor( scope: IScope, @@ -137,6 +138,7 @@ export class ResourceClass extends ClassType { }); this.makeFromCloudFormationFactory(); + this.makeFromArnFactory(); if (this.resource.cloudFormationTransform) { this.addProperty({ @@ -181,7 +183,7 @@ export class ResourceClass extends ClassType { } // BucketRef { bucketName, bucketArn } - const refPropsStruct = new StructType(this.scope, { + this.referenceStruct = new StructType(this.scope, { export: true, name: `${this.resource.name}${this.suffix ?? ''}Reference`, docs: { @@ -192,12 +194,12 @@ export class ResourceClass extends ClassType { // Build the shared interface for (const { declaration } of this.decider.referenceProps ?? []) { - refPropsStruct.addProperty(declaration); + this.referenceStruct.addProperty(declaration); } const refProperty = this.refInterface!.addProperty({ name: `${this.decider.camelResourceName}Ref`, - type: refPropsStruct.type, + type: this.referenceStruct.type, immutable: true, docs: { summary: `A reference to a ${this.resource.name} resource.`, @@ -214,6 +216,82 @@ export class ResourceClass extends ClassType { }); } + private makeFromArnFactory() { + const arnTemplate = this.resource.identifier?.arnTemplate; + if (arnTemplate == null) { + return; + } + + // Generate the inner class that is returned by the factory + const innerClass = new ClassType(this, { + name: 'ImportArn', + extends: CDK_CORE.Resource, + export: true, + implements: [this.refInterface?.type].filter(isDefined), + }); + + const refAttributeName = `${this.decider.camelResourceName}Ref`; + + innerClass.addProperty({ + name: refAttributeName, + type: this.referenceStruct!.type, + }); + + const init = innerClass.addInitializer({ + docs: { + summary: `Create a new \`${this.resource.cloudFormationType}\`.`, + }, + }); + const _scope = init.addParameter({ + name: 'scope', + type: CONSTRUCTS.Construct, + }); + const id = init.addParameter({ + name: 'id', + type: Type.STRING, + }); + const arn = init.addParameter({ + name: 'arn', + type: Type.STRING, + }); + + // Build the reference object + const variables = expr.ident('variables'); + const props = this.decider.referenceProps.map(p => p.declaration.name); + const referenceObject: Record = Object.fromEntries( + Object.entries(propsToVars(arnTemplate, props)) + .map(([prop, variable]) => [prop, expr.directCode(`variables['${variable}']`)]), + ); + const arnProp = props.find(prop => prop.endsWith('Arn')); + if (arnProp != null) { + referenceObject[arnProp] = arn; + } + + // Add the factory method to the outer class + const factory = this.addMethod({ + name: `from${this.resource.name}Arn`, + static: true, + returnType: this.refInterface?.type, + docs: { + summary: `Creates a new ${this.refInterface?.name} from an ARN`, + }, + }); + factory.addParameter({ name: 'scope', type: CONSTRUCTS.Construct }); + factory.addParameter({ name: 'id', type: Type.STRING }); + factory.addParameter({ name: 'arn', type: Type.STRING }); + + init.addBody( + new SuperInitializer(_scope, id), + stmt.sep(), + stmt.constVar(variables, $T(CDK_CORE.helpers.TemplateStringParser).parse(expr.lit(arnTemplate), arn)), + stmt.assign($this[refAttributeName], expr.object(referenceObject)), + ); + + factory.addBody( + stmt.ret(innerClass.newInstance(expr.ident('scope'), expr.ident('id'), expr.ident('arn'))), + ); + } + private makeFromCloudFormationFactory() { const factory = this.addMethod({ name: '_fromCloudFormation', @@ -457,3 +535,35 @@ export class ResourceClass extends ClassType { function isDefined(x: T | undefined): x is T { return x !== undefined; } + +/** + * Given a template like "arn:${Partition}:ec2:${Region}:${Account}:fleet/${FleetId}", + * and a list of property names, like ["partition", "region", "account", "fleetId"], + * return a mapping from property name to variable name, like: + * { + * partition: "Partition", + * region: "Region", + * account: "Account", + * fleetId: "FleetId" + * } + */ +function propsToVars(template: string, props: string[]): Record { + const variables = extractVariables(template); + const result: Record = {}; + + for (let prop of props) { + for (let variable of variables) { + const cfnProperty = propertyNameFromCloudFormation(variable); + if (prop === cfnProperty) { + result[prop] = variable; + break; + } + } + } + + return result; +} + +function extractVariables(template: string): string[] { + return (template.match(/\$\{([^}]+)\}/g) || []).map(match => match.slice(2, -1)); +} diff --git a/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap b/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap index b894cf0c9fdb7..cbef062beb1d4 100644 --- a/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap +++ b/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap @@ -1209,6 +1209,200 @@ function CfnResourcePropsFromCloudFormation(properties: any): cfn_parse.FromClou }" `; +exports[`resource with arnTemplate 1`] = ` +"/* eslint-disable prettier/prettier, @stylistic/max-len */ +import * as cdk from "aws-cdk-lib"; +import * as constructs from "constructs"; +import * as cfn_parse from "aws-cdk-lib/core/lib/helpers-internal"; +import * as cdk_errors from "aws-cdk-lib/core/lib/errors"; + +/** + * Indicates that this resource can be referenced as a Resource. + * + * @stability experimental + */ +export interface IResourceRef extends constructs.IConstruct { + /** + * A reference to a Resource resource. + */ + readonly resourceRef: ResourceReference; +} + +/** + * @cloudformationResource AWS::Some::Resource + * @stability external + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-some-resource.html + */ +export class CfnResource extends cdk.CfnResource implements cdk.IInspectable, IResourceRef { + /** + * The CloudFormation resource type name for this resource class. + */ + public static readonly CFN_RESOURCE_TYPE_NAME: string = "AWS::Some::Resource"; + + /** + * Build a CfnResource from CloudFormation properties + * + * A factory method that creates a new instance of this class from an object + * containing the CloudFormation properties of this resource. + * Used in the @aws-cdk/cloudformation-include module. + * + * @internal + */ + public static _fromCloudFormation(scope: constructs.Construct, id: string, resourceAttributes: any, options: cfn_parse.FromCloudFormationOptions): CfnResource { + resourceAttributes = resourceAttributes || {}; + const resourceProperties = options.parser.parseValue(resourceAttributes.Properties); + const propsResult = CfnResourcePropsFromCloudFormation(resourceProperties); + if (cdk.isResolvableObject(propsResult.value)) { + throw new cdk_errors.ValidationError("Unexpected IResolvable", scope); + } + const ret = new CfnResource(scope, id, propsResult.value); + for (const [propKey, propVal] of Object.entries(propsResult.extraProperties)) { + ret.addPropertyOverride(propKey, propVal); + } + options.parser.handleAttributes(ret, resourceAttributes, id); + return ret; + } + + /** + * Creates a new IResourceRef from an ARN + */ + public static fromResourceArn(scope: constructs.Construct, id: string, arn: string): IResourceRef { + return new CfnResource.ImportArn(scope, id, arn); + } + + /** + * The identifier of the resource. + */ + public id?: string; + + /** + * @param scope Scope in which this resource is defined + * @param id Construct identifier for this resource (unique in its scope) + * @param props Resource properties + */ + public constructor(scope: constructs.Construct, id: string, props: CfnResourceProps = {}) { + super(scope, id, { + "type": CfnResource.CFN_RESOURCE_TYPE_NAME, + "properties": props + }); + + this.id = props.id; + } + + public get resourceRef(): ResourceReference { + return { + "resourceId": this.ref + }; + } + + protected get cfnProperties(): Record { + return { + "id": this.id + }; + } + + /** + * Examines the CloudFormation resource and discloses attributes + * + * @param inspector tree inspector to collect and process attributes + */ + public inspect(inspector: cdk.TreeInspector): void { + inspector.addAttribute("aws:cdk:cloudformation:type", CfnResource.CFN_RESOURCE_TYPE_NAME); + inspector.addAttribute("aws:cdk:cloudformation:props", this.cfnProperties); + } + + protected renderProperties(props: Record): Record { + return convertCfnResourcePropsToCloudFormation(props); + } +} + +export namespace CfnResource { + export class ImportArn extends cdk.Resource implements IResourceRef { + public resourceRef: ResourceReference; + + public constructor(scope: constructs.Construct, id: string, arn: string) { + super(scope, id); + + const variables = cfn_parse.TemplateStringParser.parse("arn:\${Partition}:some:\${Region}:\${Account}:resource/\${ResourceId}", arn); + this.resourceRef = { + "resourceId": variables['ResourceId'] + }; + } + } +} + +/** + * Properties for defining a \`CfnResource\` + * + * @struct + * @stability external + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-some-resource.html + */ +export interface CfnResourceProps { + /** + * The identifier of the resource. + * + * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-some-resource.html#cfn-some-resource-id + */ + readonly id?: string; +} + +/** + * A reference to a Resource resource. + * + * @struct + * @stability external + */ +export interface ResourceReference { + /** + * The Id of the Resource resource. + */ + readonly resourceId: string; +} + +/** + * Determine whether the given properties match those of a \`CfnResourceProps\` + * + * @param properties - the TypeScript properties of a \`CfnResourceProps\` + * + * @returns the result of the validation. + */ +// @ts-ignore TS6133 +function CfnResourcePropsValidator(properties: any): cdk.ValidationResult { + if (!cdk.canInspect(properties)) return cdk.VALIDATION_SUCCESS; + const errors = new cdk.ValidationResults(); + if (!(properties && typeof properties == 'object' && !Array.isArray(properties))) { + errors.collect(new cdk.ValidationResult("Expected an object, but received: " + JSON.stringify(properties))); + } + errors.collect(cdk.propertyValidator("id", cdk.validateString)(properties.id)); + return errors.wrap("supplied properties not correct for \\"CfnResourceProps\\""); +} + +// @ts-ignore TS6133 +function convertCfnResourcePropsToCloudFormation(properties: any): any { + if (!cdk.canInspect(properties)) return properties; + CfnResourcePropsValidator(properties).assertSuccess(); + return { + "Id": cdk.stringToCloudFormation(properties.id) + }; +} + +// @ts-ignore TS6133 +function CfnResourcePropsFromCloudFormation(properties: any): cfn_parse.FromCloudFormationResult { + if (cdk.isResolvableObject(properties)) { + return new cfn_parse.FromCloudFormationResult(properties); + } + properties = ((properties == null) ? {} : properties); + if (!(properties && typeof properties == 'object' && !Array.isArray(properties))) { + return new cfn_parse.FromCloudFormationResult(properties); + } + const ret = new cfn_parse.FromCloudFormationPropertyObject(); + ret.addPropertyResult("id", "Id", (properties.Id != null ? cfn_parse.FromCloudFormation.getString(properties.Id) : undefined)); + ret.addUnrecognizedPropertiesAsExtra(properties); + return ret; +}" +`; + exports[`resource with multiple primaryIdentifiers as properties 1`] = ` "/* eslint-disable prettier/prettier, @stylistic/max-len */ import * as cdk from "aws-cdk-lib"; diff --git a/tools/@aws-cdk/spec2cdk/test/resources.test.ts b/tools/@aws-cdk/spec2cdk/test/resources.test.ts index ca0d00b771f75..69af45d79a898 100644 --- a/tools/@aws-cdk/spec2cdk/test/resources.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/resources.test.ts @@ -43,6 +43,36 @@ test('resource interface when primaryIdentifier is a property', () => { expect(rendered).toMatchSnapshot(); }); +test('resource with arnTemplate', () => { + // GIVEN + const resource = db.allocate('resource', { + name: 'Resource', + primaryIdentifier: ['Id'], + attributes: {}, + properties: { + Id: { + type: { type: 'string' }, + documentation: 'The identifier of the resource', + }, + }, + cloudFormationType: 'AWS::Some::Resource', + identifier: { + $id: '42', + arnTemplate: 'arn:${Partition}:some:${Region}:${Account}:resource/${ResourceId}', + }, + }); + db.link('hasResource', service, resource); + + // THEN + const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); + + const ast = AstBuilder.forResource(foundResource, { db }); + + const rendered = renderer.render(ast.module); + + expect(rendered).toMatchSnapshot(); +}); + test('resource with optional primary identifier gets property from ref', () => { // GIVEN const resource = db.allocate('resource', { From fa02d2a7e5123a59e3f4bb892db848cfcdf0e301 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:20:39 +0100 Subject: [PATCH 02/10] Test for L1 instead of L2 --- packages/aws-cdk-lib/aws-s3/test/bucket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts index 8253a504ddbfb..8b6c536860fe8 100644 --- a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts +++ b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts @@ -5226,7 +5226,7 @@ describe('bucket', () => { describe('L1 static factory methods', () => { test('fromBucketArn', () => { const stack = new cdk.Stack(); - const bucket = s3.Bucket.fromBucketArn(stack, 'MyBucket', 'arn:aws:s3:::my-bucket-name'); + const bucket = s3.CfnBucket.fromBucketArn(stack, 'MyBucket', 'arn:aws:s3:::my-bucket-name'); expect(bucket.bucketRef.bucketName).toEqual('my-bucket-name'); expect(bucket.bucketRef.bucketArn).toEqual('arn:aws:s3:::my-bucket-name'); }); From 8e468d89e7f43421deb15c8872db3977133515a9 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:58:19 +0100 Subject: [PATCH 03/10] from --- .../aws-dynamodb/test/dynamodb.test.ts | 34 ++- .../aws-cdk-lib/aws-s3/test/bucket.test.ts | 9 - .../core/lib/helpers-internal/strings.ts | 9 + .../test/helpers-internal/strings.test.ts | 131 ++++++++---- tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts | 1 + .../spec2cdk/lib/cdk/resource-class.ts | 196 ++++++++++++++---- .../spec2cdk/lib/cdk/resource-decider.ts | 2 +- 7 files changed, 295 insertions(+), 87 deletions(-) diff --git a/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts b/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts index 3e0a3b6a352f2..c029439f552b8 100644 --- a/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts +++ b/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts @@ -7,7 +7,7 @@ import * as iam from '../../aws-iam'; import * as kinesis from '../../aws-kinesis'; import * as kms from '../../aws-kms'; import * as s3 from '../../aws-s3'; -import { App, Aws, CfnDeletionPolicy, Duration, PhysicalName, RemovalPolicy, Resource, Stack, Tags } from '../../core'; +import { App, ArnFormat, Aws, CfnDeletionPolicy, Duration, PhysicalName, RemovalPolicy, Resource, Stack, Tags } from '../../core'; import * as cr from '../../custom-resources'; import * as cxapi from '../../cx-api'; import { @@ -397,6 +397,38 @@ describe('default properties', () => { }); }); +describe('L1 static factory methods', () => { + test('fromTableArn', () => { + const stack = new Stack(); + const table = CfnTable.fromTableArn(stack, 'MyBucket', 'arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable'); + expect(table.tableRef.tableName).toEqual('MyTable'); + expect(table.tableRef.tableArn).toEqual('arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable'); + }); + + test('fromTableName', () => { + const app = new App(); + const stack = new Stack(app, 'MyStack', { + env: { account: '23432424', region: 'us-east-1' }, + }); + + const table = CfnTable.fromTableName(stack, 'Table', 'MyTable'); + const arnComponents = stack.splitArn(table.tableRef.tableArn, ArnFormat.SLASH_RESOURCE_NAME); + + expect(table.tableRef.tableName).toEqual('MyTable'); + expect(arnComponents).toMatchObject({ + account: '23432424', + region: 'us-east-1', + resource: 'table', + resourceName: 'MyTable', + service: 'dynamodb', + }); + + expect(stack.resolve(arnComponents.partition)).toEqual({ + Ref: 'AWS::Partition', + }); + }); +}); + testDeprecated('when specifying every property', () => { const stack = new Stack(); const stream = new kinesis.Stream(stack, 'MyStream'); diff --git a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts index 8b6c536860fe8..f15beef2085e3 100644 --- a/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts +++ b/packages/aws-cdk-lib/aws-s3/test/bucket.test.ts @@ -5222,15 +5222,6 @@ describe('bucket', () => { ); }); }); - - describe('L1 static factory methods', () => { - test('fromBucketArn', () => { - const stack = new cdk.Stack(); - const bucket = s3.CfnBucket.fromBucketArn(stack, 'MyBucket', 'arn:aws:s3:::my-bucket-name'); - expect(bucket.bucketRef.bucketName).toEqual('my-bucket-name'); - expect(bucket.bucketRef.bucketArn).toEqual('arn:aws:s3:::my-bucket-name'); - }); - }); }); class AccessBucketInjector implements cdk.IPropertyInjector { diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts index 76ce37a569f37..803a4037a22d6 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts @@ -51,4 +51,13 @@ export class TemplateStringParser { return result; } + + public static interpolate(template: string, variables: Record): string { + return template.replace(/\$\{([^}]+)\}/g, (_, varName) => { + if (variables[varName] === undefined) { + throw new UnscopedValidationError(`Variable ${varName} not provided for template interpolation`); + } + return variables[varName]; + }); + } } diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts index 58199a25ef93d..56c28ea5496bf 100644 --- a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts +++ b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts @@ -2,56 +2,105 @@ import { UnscopedValidationError } from '../../lib'; import { TemplateStringParser } from '../../lib/helpers-internal/strings'; describe('TemplateStringParser', () => { - it('parses template with single variable correctly', () => { - const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!'); - expect(result).toEqual({ name: 'John' }); - }); + describe('parse', () => { + it('parses template with single variable correctly', () => { + const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!'); + expect(result).toEqual({ name: 'John' }); + }); - it('parses template with multiple variables correctly', () => { - const result = TemplateStringParser.parse('My name is ${firstName} ${lastName}.', 'My name is Jane Doe.'); - expect(result).toEqual({ firstName: 'Jane', lastName: 'Doe' }); - }); + it('parses template with multiple variables correctly', () => { + const result = TemplateStringParser.parse('My name is ${firstName} ${lastName}.', 'My name is Jane Doe.'); + expect(result).toEqual({ firstName: 'Jane', lastName: 'Doe' }); + }); - it('throws error when input does not match template', () => { - expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hi, John!'); - }).toThrow(UnscopedValidationError); - }); + it('throws error when input does not match template', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hi, John!'); + }).toThrow(UnscopedValidationError); + }); - it('parses template with no variables correctly', () => { - const result = TemplateStringParser.parse('Hello, world!', 'Hello, world!'); - expect(result).toEqual({}); - }); + it('parses template with no variables correctly', () => { + const result = TemplateStringParser.parse('Hello, world!', 'Hello, world!'); + expect(result).toEqual({}); + }); - it('parses template with trailing variable correctly', () => { - const result = TemplateStringParser.parse('Path: ${path}', 'Path: /home/user'); - expect(result).toEqual({ path: '/home/user' }); - }); + it('parses template with trailing variable correctly', () => { + const result = TemplateStringParser.parse('Path: ${path}', 'Path: /home/user'); + expect(result).toEqual({ path: '/home/user' }); + }); - it('throws error when input has extra characters', () => { - expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!!'); - }).toThrow(UnscopedValidationError); - }); + it('throws error when input has extra characters', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!!'); + }).toThrow(UnscopedValidationError); + }); - it('parses template with adjacent variables correctly', () => { - const result = TemplateStringParser.parse('${greeting}, ${name}!', 'Hi, John!'); - expect(result).toEqual({ greeting: 'Hi', name: 'John' }); - }); + it('parses template with adjacent variables correctly', () => { + const result = TemplateStringParser.parse('${greeting}, ${name}!', 'Hi, John!'); + expect(result).toEqual({ greeting: 'Hi', name: 'John' }); + }); - it('throws error when input is shorter than template', () => { - expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hello, '); - }).toThrow(UnscopedValidationError); - }); + it('throws error when input is shorter than template', () => { + expect(() => { + TemplateStringParser.parse('Hello, ${name}!', 'Hello, '); + }).toThrow(UnscopedValidationError); + }); - it('parses template with empty variable value correctly', () => { - const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, !'); - expect(result).toEqual({ name: '' }); + it('parses template with empty variable value correctly', () => { + const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, !'); + expect(result).toEqual({ name: '' }); + }); + + it('parses template with variable at the start correctly', () => { + const result = TemplateStringParser.parse('${greeting}, world!', 'Hi, world!'); + expect(result).toEqual({ greeting: 'Hi' }); + }); }); - it('parses template with variable at the start correctly', () => { - const result = TemplateStringParser.parse('${greeting}, world!', 'Hi, world!'); - expect(result).toEqual({ greeting: 'Hi' }); + describe('interpolate', () => { + it('interpolates template with single variable correctly', () => { + const result = TemplateStringParser.interpolate('Hello, ${name}!', { name: 'John' }); + expect(result).toBe('Hello, John!'); + }); + + it('interpolates template with multiple variables correctly', () => { + const result = TemplateStringParser.interpolate('My name is ${firstName} ${lastName}.', { + firstName: 'Jane', + lastName: 'Doe', + }); + expect(result).toBe('My name is Jane Doe.'); + }); + + it('throws error when variable is missing in interpolation', () => { + expect(() => { + TemplateStringParser.interpolate('Hello, ${name}!', {}); + }).toThrow(UnscopedValidationError); + }); + + it('interpolates template with no variables correctly', () => { + const result = TemplateStringParser.interpolate('Hello, world!', {}); + expect(result).toBe('Hello, world!'); + }); + + it('throws error when template contains undefined variable', () => { + expect(() => { + TemplateStringParser.interpolate('Hello, ${name}!', { greeting: 'Hi' }); + }).toThrow(UnscopedValidationError); + }); + + it('interpolates template with adjacent variables correctly', () => { + const result = TemplateStringParser.interpolate('${greeting}, ${name}!', { greeting: 'Hi', name: 'John' }); + expect(result).toBe('Hi, John!'); + }); + + it('interpolates template with empty variable value correctly', () => { + const result = TemplateStringParser.interpolate('Hello, ${name}!', { name: '' }); + expect(result).toBe('Hello, !'); + }); + + it('interpolates template with variable at the start correctly', () => { + const result = TemplateStringParser.interpolate('${greeting}, world!', { greeting: 'Hi' }); + expect(result).toBe('Hi, world!'); + }); }); }); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts index fd5b72c7398a2..e6331ee799962 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts @@ -41,6 +41,7 @@ export class CdkCore extends ExternalModule { public readonly ITaggable = Type.fromName(this, 'ITaggable'); public readonly ITaggableV2 = Type.fromName(this, 'ITaggableV2'); public readonly IResolvable = Type.fromName(this, 'IResolvable'); + public readonly Stack = Type.fromName(this, 'Stack'); public readonly objectToCloudFormation = makeCallableExpr(this, 'objectToCloudFormation'); public readonly stringToCloudFormation = makeCallableExpr(this, 'stringToCloudFormation'); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts index ce5afc51cf0e2..92f3dc2f92f4d 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts @@ -15,7 +15,7 @@ import { MemberVisibility, Module, ObjectLiteral, - Stability, + Stability, Statement, stmt, StructType, SuperInitializer, @@ -31,7 +31,7 @@ import { cfnProducerNameFromType, classNameFromResource, cloudFormationDocLink, propertyNameFromCloudFormation, - propStructNameFromResource, + propStructNameFromResource, referencePropertyName, staticRequiredTransform, staticResourceTypeName, } from '../naming'; @@ -139,6 +139,7 @@ export class ResourceClass extends ClassType { this.makeFromCloudFormationFactory(); this.makeFromArnFactory(); + this.makeFromNameFactory(); if (this.resource.cloudFormationTransform) { this.addProperty({ @@ -218,7 +219,30 @@ export class ResourceClass extends ClassType { private makeFromArnFactory() { const arnTemplate = this.resource.identifier?.arnTemplate; - if (arnTemplate == null) { + if (!(arnTemplate && this.refInterface && this.referenceStruct)) { + // We don't have enough information to build this factory + return; + } + + const cfnArnProperty = this.decider.findArnProperty(); + if (cfnArnProperty == null) { + return; + } + + const arnPropertyName = referencePropertyName(cfnArnProperty, this.resource.name); + + // Build the reference object + const variables = expr.ident('variables'); + const props = this.decider.referenceProps.map(p => p.declaration.name); + + const referenceObject: Record = Object.fromEntries( + Object.entries(propsToVars(arnTemplate, props)) + .map(([prop, variable]) => [prop, $E(variables).prop(variable)]), + ); + const hasNonArnProps = Object.keys(referenceObject).length > 0; + + if (!setEqual(Object.keys(referenceObject), props.filter(p => p !== arnPropertyName))) { + // Not all properties could be derived from the ARN. We can't continue. return; } @@ -242,29 +266,15 @@ export class ResourceClass extends ClassType { summary: `Create a new \`${this.resource.cloudFormationType}\`.`, }, }); - const _scope = init.addParameter({ - name: 'scope', - type: CONSTRUCTS.Construct, - }); - const id = init.addParameter({ - name: 'id', - type: Type.STRING, - }); + const _scope = mkScope(init); + const id = mkId(init); const arn = init.addParameter({ name: 'arn', type: Type.STRING, }); - // Build the reference object - const variables = expr.ident('variables'); - const props = this.decider.referenceProps.map(p => p.declaration.name); - const referenceObject: Record = Object.fromEntries( - Object.entries(propsToVars(arnTemplate, props)) - .map(([prop, variable]) => [prop, expr.directCode(`variables['${variable}']`)]), - ); - const arnProp = props.find(prop => prop.endsWith('Arn')); - if (arnProp != null) { - referenceObject[arnProp] = arn; + if (arnPropertyName != null) { + referenceObject[arnPropertyName] = arn; } // Add the factory method to the outer class @@ -280,18 +290,118 @@ export class ResourceClass extends ClassType { factory.addParameter({ name: 'id', type: Type.STRING }); factory.addParameter({ name: 'arn', type: Type.STRING }); - init.addBody( + const initBodyStatements: Statement[] = [ new SuperInitializer(_scope, id), stmt.sep(), - stmt.constVar(variables, $T(CDK_CORE.helpers.TemplateStringParser).parse(expr.lit(arnTemplate), arn)), - stmt.assign($this[refAttributeName], expr.object(referenceObject)), - ); + ]; + + if (hasNonArnProps) { + initBodyStatements.push( + stmt.constVar(variables, $T(CDK_CORE.helpers.TemplateStringParser).parse(expr.lit(arnTemplate), arn)), + ); + } + initBodyStatements.push(stmt.assign($this[refAttributeName], expr.object(referenceObject))); + + init.addBody(...initBodyStatements); factory.addBody( stmt.ret(innerClass.newInstance(expr.ident('scope'), expr.ident('id'), expr.ident('arn'))), ); } + private makeFromNameFactory() { + const arnTemplate = this.resource.identifier?.arnTemplate; + if (!(arnTemplate && this.refInterface && this.referenceStruct)) { + // We don't have enough information to build this factory + return; + } + + const propsWithoutArn = this.decider.referenceProps.filter(prop => !prop.declaration.name.endsWith('Arn')); + const allVariables = extractVariables(arnTemplate); + const onlyProperties = allVariables.filter(v => !['Partition', 'Region', 'Account'].includes(v)); + + if (propsWithoutArn.length !== 1 || onlyProperties.length !== 1) { + // Only generate the method if there is exactly one non-ARN prop in the Reference interface + // and only one variable in the ARN template that is not Partition, Region or Account + return; + } + + const propName = propsWithoutArn[0].declaration.name; + const variableName = allVariables.find(v => propertyNameFromCloudFormation(v) === propName); + if (variableName == null) { + // The template doesn't contain a variable that matches the property name. We can't continue. + return; + } + + // Generate the inner class + const innerClass = new ClassType(this, { + name: 'ImportName', + extends: CDK_CORE.Resource, + export: true, + }); + + const refAttributeName = `${this.decider.camelResourceName}Ref`; + innerClass.addProperty({ + name: refAttributeName, + type: this.referenceStruct!.type, + }); + + const init = innerClass.addInitializer({ + docs: { + summary: `Create a new \`${this.resource.cloudFormationType}\`.`, + }, + }); + const _scope = mkScope(init); + const id = mkId(init); + const name = init.addParameter({ + name: propName, + type: Type.STRING, + }); + + const interpolateArn = $T(CDK_CORE.helpers.TemplateStringParser).interpolate(expr.lit(arnTemplate), expr.object({ + Partition: $T(CDK_CORE.Stack).of(_scope).prop('partition'), + Region: $T(CDK_CORE.Stack).of(_scope).prop('region'), + Account: $T(CDK_CORE.Stack).of(_scope).prop('account'), + [variableName]: name, + })); + + const refenceObject: Record = { + [propName]: name, + }; + + const initBodyStatements: Statement[] = [ + new SuperInitializer(_scope, id), + stmt.sep(), + ]; + + const arnPropName = this.referenceStruct.properties.map(p => p.name).find(n => n.endsWith('Arn')); + const arn = expr.ident('arn'); + if (arnPropName != null) { + refenceObject[arnPropName] = arn; + initBodyStatements.push(stmt.constVar(arn, interpolateArn)); + } + initBodyStatements.push(stmt.assign($this[refAttributeName], expr.object(refenceObject))); + + init.addBody(...initBodyStatements); + + // Add the factory method to the outer class + const factory = this.addMethod({ + name: `from${variableName}`, + static: true, + returnType: this.refInterface!.type, + docs: { + summary: `Creates a new ${this.refInterface!.name} from a ${propName}`, + }, + }); + factory.addParameter({ name: 'scope', type: CONSTRUCTS.Construct }); + factory.addParameter({ name: 'id', type: Type.STRING }); + factory.addParameter({ name: propName, type: Type.STRING }); + + factory.addBody( + stmt.ret(innerClass.newInstance(expr.ident('scope'), expr.ident('id'), expr.ident(propName))), + ); + } + private makeFromCloudFormationFactory() { const factory = this.addMethod({ name: '_fromCloudFormation', @@ -355,16 +465,8 @@ export class ResourceClass extends ClassType { summary: `Create a new \`${this.resource.cloudFormationType}\`.`, }, }); - const _scope = init.addParameter({ - name: 'scope', - type: CONSTRUCTS.Construct, - documentation: 'Scope in which this resource is defined', - }); - const id = init.addParameter({ - name: 'id', - type: Type.STRING, - documentation: 'Construct identifier for this resource (unique in its scope)', - }); + const _scope = mkScope(init); + const id = mkId(init); const hasRequiredProps = this.propsType.properties.some((p) => !p.optional); const props = init.addParameter({ @@ -567,3 +669,27 @@ function propsToVars(template: string, props: string[]): Record function extractVariables(template: string): string[] { return (template.match(/\$\{([^}]+)\}/g) || []).map(match => match.slice(2, -1)); } + +function mkScope(init: Initializer) { + return init.addParameter({ + name: 'scope', + type: CONSTRUCTS.Construct, + documentation: 'Scope in which this resource is defined', + }); +} + +function mkId(init: Initializer) { + return init.addParameter({ + name: 'id', + type: Type.STRING, + documentation: 'Construct identifier for this resource (unique in its scope)', + }); +} + +/** + * Whether the given sets are equal + */ +function setEqual(a: A[], b: A[]) { + const bSet = new Set(b); + return a.length === b.length && a.every(k => bSet.has(k)); +} diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-decider.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-decider.ts index a8db9a5c4c041..6a8a4a627ca2a 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-decider.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-decider.ts @@ -86,7 +86,7 @@ export class ResourceDecider { * Returns `undefined` if no ARN property is found, or if the ARN property is already * included in the primary identifier. */ - private findArnProperty() { + public findArnProperty() { const possibleArnNames = ['Arn', `${this.resource.name}Arn`]; for (const name of possibleArnNames) { const prop = this.resource.attributes[name]; From 1588cdd9d737255ed933053cd34b7fc8576a7358 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:50:11 +0100 Subject: [PATCH 04/10] Consume `arnTemplate` directly from the `Resource` type --- packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts | 2 +- tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts | 6 +++--- tools/@aws-cdk/spec2cdk/test/resources.test.ts | 5 +---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts index 803a4037a22d6..c0317cbb6450e 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts @@ -53,7 +53,7 @@ export class TemplateStringParser { } public static interpolate(template: string, variables: Record): string { - return template.replace(/\$\{([^}]+)\}/g, (_, varName) => { + return template.replace(/\${([^{}]+)}/g, (_, varName) => { if (variables[varName] === undefined) { throw new UnscopedValidationError(`Variable ${varName} not provided for template interpolation`); } diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts index 92f3dc2f92f4d..b27f3d693ef4c 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts @@ -218,7 +218,7 @@ export class ResourceClass extends ClassType { } private makeFromArnFactory() { - const arnTemplate = this.resource.identifier?.arnTemplate; + const arnTemplate = this.resource.arnTemplate; if (!(arnTemplate && this.refInterface && this.referenceStruct)) { // We don't have enough information to build this factory return; @@ -310,7 +310,7 @@ export class ResourceClass extends ClassType { } private makeFromNameFactory() { - const arnTemplate = this.resource.identifier?.arnTemplate; + const arnTemplate = this.resource.arnTemplate; if (!(arnTemplate && this.refInterface && this.referenceStruct)) { // We don't have enough information to build this factory return; @@ -667,7 +667,7 @@ function propsToVars(template: string, props: string[]): Record } function extractVariables(template: string): string[] { - return (template.match(/\$\{([^}]+)\}/g) || []).map(match => match.slice(2, -1)); + return (template.match(/\${([^{}]+)}/g) || []).map(match => match.slice(2, -1)); } function mkScope(init: Initializer) { diff --git a/tools/@aws-cdk/spec2cdk/test/resources.test.ts b/tools/@aws-cdk/spec2cdk/test/resources.test.ts index 69af45d79a898..ae41761690096 100644 --- a/tools/@aws-cdk/spec2cdk/test/resources.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/resources.test.ts @@ -56,10 +56,7 @@ test('resource with arnTemplate', () => { }, }, cloudFormationType: 'AWS::Some::Resource', - identifier: { - $id: '42', - arnTemplate: 'arn:${Partition}:some:${Region}:${Account}:resource/${ResourceId}', - }, + arnTemplate: 'arn:${Partition}:some:${Region}:${Account}:resource/${ResourceId}', }); db.link('hasResource', service, resource); From 92ee14b91fa7d4b9483b6391599f37fd4b6c9c6a Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:06:37 +0100 Subject: [PATCH 05/10] Inner class inside the method --- .../core/lib/helpers-internal/strings.ts | 2 +- .../test/helpers-internal/strings.test.ts | 13 ++++++++ .../spec2cdk/lib/cdk/resource-class.ts | 32 +++++++++++-------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts index c0317cbb6450e..b004ddaaf3c1c 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts @@ -12,7 +12,7 @@ export class TemplateStringParser { * @throws UnscopedValidationError if the input does not match the template */ public static parse(template: string, input: string): Record { - const templateParts = template.split(/(\$\{[^}]+\})/); + const templateParts = template.split(/(\$\{[^{}]+})/); const result: Record = {}; let inputIndex = 0; diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts index 56c28ea5496bf..a13391e9124ac 100644 --- a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts +++ b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts @@ -55,6 +55,19 @@ describe('TemplateStringParser', () => { const result = TemplateStringParser.parse('${greeting}, world!', 'Hi, world!'); expect(result).toEqual({ greeting: 'Hi' }); }); + + it('parses complex template correctly', () => { + const result = TemplateStringParser.parse( + 'arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}', + 'arn:aws:dynamodb:us-east-1:12345:table/MyTable', + ); + expect(result).toEqual({ + Partition: 'aws', + Region: 'us-east-1', + Account: '12345', + TableName: 'MyTable', + }); + }); }); describe('interpolate', () => { diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts index b27f3d693ef4c..d33c493b3f518 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts @@ -6,6 +6,7 @@ import { Block, ClassType, code, + DummyScope, expr, Expression, Initializer, InterfaceType, @@ -19,8 +20,10 @@ import { stmt, StructType, SuperInitializer, + ThingSymbol, TruthyOr, Type, + TypeDeclarationStatement, } from '@cdklabs/typewriter'; import { CDK_CORE, CONSTRUCTS } from './cdk'; import { CloudFormationMapping } from './cloudformation-mapping'; @@ -246,14 +249,7 @@ export class ResourceClass extends ClassType { return; } - // Generate the inner class that is returned by the factory - const innerClass = new ClassType(this, { - name: 'ImportArn', - extends: CDK_CORE.Resource, - export: true, - implements: [this.refInterface?.type].filter(isDefined), - }); - + const innerClass = mkImportClass(this.scope); const refAttributeName = `${this.decider.camelResourceName}Ref`; innerClass.addProperty({ @@ -305,6 +301,7 @@ export class ResourceClass extends ClassType { init.addBody(...initBodyStatements); factory.addBody( + new TypeDeclarationStatement(innerClass), stmt.ret(innerClass.newInstance(expr.ident('scope'), expr.ident('id'), expr.ident('arn'))), ); } @@ -333,12 +330,7 @@ export class ResourceClass extends ClassType { return; } - // Generate the inner class - const innerClass = new ClassType(this, { - name: 'ImportName', - extends: CDK_CORE.Resource, - export: true, - }); + const innerClass = mkImportClass(this.scope); const refAttributeName = `${this.decider.camelResourceName}Ref`; innerClass.addProperty({ @@ -398,6 +390,7 @@ export class ResourceClass extends ClassType { factory.addParameter({ name: propName, type: Type.STRING }); factory.addBody( + new TypeDeclarationStatement(innerClass), stmt.ret(innerClass.newInstance(expr.ident('scope'), expr.ident('id'), expr.ident(propName))), ); } @@ -693,3 +686,14 @@ function setEqual(a: A[], b: A[]) { const bSet = new Set(b); return a.length === b.length && a.every(k => bSet.has(k)); } + +function mkImportClass(largerScope: IScope): ClassType { + const scope = new DummyScope(); + const className = 'Import'; + const innerClass = new ClassType(scope, { + name: className, + extends: CDK_CORE.Resource, + }); + largerScope.linkSymbol(new ThingSymbol(className, scope), expr.ident(className)); + return innerClass; +} From dbb025b63ed5c86006c39d53f0bba02b444a9121 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:05:06 +0100 Subject: [PATCH 06/10] Update dependencies --- .../custom-resource-handlers/package.json | 2 +- packages/aws-cdk-lib/package.json | 3 +- tools/@aws-cdk/spec2cdk/package.json | 8 ++-- yarn.lock | 43 ++++++++----------- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/packages/@aws-cdk/custom-resource-handlers/package.json b/packages/@aws-cdk/custom-resource-handlers/package.json index 6228e009bf8ec..68d9ea8eeaa08 100644 --- a/packages/@aws-cdk/custom-resource-handlers/package.json +++ b/packages/@aws-cdk/custom-resource-handlers/package.json @@ -48,7 +48,7 @@ "@types/jest": "^29.5.14", "aws-sdk-client-mock": "4.1.0", "aws-sdk-client-mock-jest": "4.1.0", - "@cdklabs/typewriter": "^0.0.5", + "@cdklabs/typewriter": "^0.0.6", "jest": "^29.7.0", "sinon": "^9.2.4", "nock": "^13.5.6", diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index 433d8dbc20318..0e91586fa6a92 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -136,7 +136,7 @@ }, "devDependencies": { "@aws-cdk/lambda-layer-kubectl-v31": "^2.1.0", - "@aws-cdk/aws-service-spec": "^0.1.95", + "@aws-cdk/aws-service-spec": "^0.1.97", "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/custom-resource-handlers": "0.0.0", "@aws-cdk/pkglint": "0.0.0", @@ -479,6 +479,7 @@ "./aws-shield": "./aws-shield/index.js", "./aws-signer": "./aws-signer/index.js", "./aws-simspaceweaver": "./aws-simspaceweaver/index.js", + "./aws-smsvoice": "./aws-smsvoice/index.js", "./aws-sns": "./aws-sns/index.js", "./aws-sns-subscriptions": "./aws-sns-subscriptions/index.js", "./aws-sqs": "./aws-sqs/index.js", diff --git a/tools/@aws-cdk/spec2cdk/package.json b/tools/@aws-cdk/spec2cdk/package.json index f30a94d1e4884..82d1048fe9440 100644 --- a/tools/@aws-cdk/spec2cdk/package.json +++ b/tools/@aws-cdk/spec2cdk/package.json @@ -32,11 +32,11 @@ }, "license": "Apache-2.0", "dependencies": { - "@aws-cdk/aws-service-spec": "^0.1.95", - "@aws-cdk/service-spec-importers": "^0.0.84", - "@aws-cdk/service-spec-types": "^0.0.161", + "@aws-cdk/aws-service-spec": "^0.1.97", + "@aws-cdk/service-spec-importers": "^0.0.86", + "@aws-cdk/service-spec-types": "^0.0.163", "@cdklabs/tskb": "^0.0.3", - "@cdklabs/typewriter": "^0.0.5", + "@cdklabs/typewriter": "^0.0.6", "camelcase": "^6", "fs-extra": "^9", "p-limit": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index af41825a6513b..35e73177576b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -66,12 +66,12 @@ "@aws-cdk/service-spec-types" "^0.0.158" "@cdklabs/tskb" "^0.0.3" -"@aws-cdk/aws-service-spec@^0.1.95": - version "0.1.95" - resolved "https://registry.npmjs.org/@aws-cdk/aws-service-spec/-/aws-service-spec-0.1.95.tgz#f41c70c99906975cf2f9eb9de0c09780a896d917" - integrity sha512-3Iaz/XBg2Bg8uycpcosSEUHUUdaxdgKHOUxylLiX9DpD6GCE5Ken+xfmvB73lFT5oba3Zo9E3xbTlThCy61WWg== +"@aws-cdk/aws-service-spec@^0.1.97": + version "0.1.97" + resolved "https://registry.npmjs.org/@aws-cdk/aws-service-spec/-/aws-service-spec-0.1.97.tgz#84b1225b0d368ede9b3d282a31402a92e414fc5e" + integrity sha512-duuyKGHfNhZJGtCBmodLz7Xh2lsdhJSqd0qFL/suWs9+Z1c1y0eEQaoCKXz6rNu/6GrFNtPOVNNY8yA4gt6AkA== dependencies: - "@aws-cdk/service-spec-types" "^0.0.161" + "@aws-cdk/service-spec-types" "^0.0.163" "@cdklabs/tskb" "^0.0.3" "@aws-cdk/cloud-assembly-schema@^48.6.0": @@ -122,12 +122,12 @@ resolved "https://registry.npmjs.org/@aws-cdk/lambda-layer-kubectl-v33/-/lambda-layer-kubectl-v33-2.0.0.tgz#0c521ad987bb3ac597a4b4ebeece8fd2721ca000" integrity sha512-osA3wkwWK2OfpymTcCZKhgaKSca9PQSr+7xi+UevKFRHtMdxHgygC345hdDpCtZlMmX9pKjtFpRUxeRrbGHMEw== -"@aws-cdk/service-spec-importers@^0.0.84": - version "0.0.84" - resolved "https://registry.npmjs.org/@aws-cdk/service-spec-importers/-/service-spec-importers-0.0.84.tgz#f261345c5bccfac0c2ff3d854c5247d677b6091a" - integrity sha512-iVuiU2nCnlDnJETWscboiTqdu/PWQrTQuNA3M0TA4YdidJd5tlG2El/m9eF3OHe+8RLSixZsG7xKbtMzwjqaOg== +"@aws-cdk/service-spec-importers@^0.0.86": + version "0.0.86" + resolved "https://registry.npmjs.org/@aws-cdk/service-spec-importers/-/service-spec-importers-0.0.86.tgz#229ee83b894d4f463edd8896ce0c187821aeaa6a" + integrity sha512-3p0MO62FAVF2U+S2VjKRGFQRjZtzk9Z5EWOnYRxtt4NlhxEDeYgPXBDTwPAuWVkQM2XVaDUcLv5G/kXgEKCUVQ== dependencies: - "@aws-cdk/service-spec-types" "^0.0.160" + "@aws-cdk/service-spec-types" "^0.0.163" "@cdklabs/tskb" "^0.0.3" ajv "^6" canonicalize "^2.1.0" @@ -145,17 +145,10 @@ dependencies: "@cdklabs/tskb" "^0.0.3" -"@aws-cdk/service-spec-types@^0.0.160": - version "0.0.160" - resolved "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.160.tgz#3c37e324418db15a4820f957b95d282244b1743c" - integrity sha512-uTSZyc6yBbX7SYSvnPi0EkvGGqUbo2Xc2O2urtNTviv2v9vRROMJNxvWc8QIobDYe+KEP3QcgbZTHKxI0apuKQ== - dependencies: - "@cdklabs/tskb" "^0.0.3" - -"@aws-cdk/service-spec-types@^0.0.161": - version "0.0.161" - resolved "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.161.tgz#bf0f42b44156a5c0d58ab27c8050a4baa1956dfb" - integrity sha512-GT1QaeEP3AKnwEerB3wGQ15OM5QPX8xVkjF/wq71RkX4rnqeMX4h4OSZFZUV/8gW2j2c74LHwPIJ15xSTBt54w== +"@aws-cdk/service-spec-types@^0.0.163": + version "0.0.163" + resolved "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.163.tgz#ba07b771c479211900675e149d0c1a465dee1099" + integrity sha512-IheocTkQUn9bxe7SsEYk1G0Tb7vlNELNSan3VxHjvgKijUWden14xQJivovooV2sfjFRiAnOPjnUbGTttrHSrw== dependencies: "@cdklabs/tskb" "^0.0.3" @@ -2515,10 +2508,10 @@ resolved "https://registry.npmjs.org/@cdklabs/tskb/-/tskb-0.0.3.tgz#4b79846d9381eb1252ba85d5d20b7cd7d99b6ecb" integrity sha512-JR+MuD4awAXvutu7HArephXfZm09GPTaSAQUqNcJB5+ZENRm4kV+L6vJL6Tn1xHjCcHksO+HAqj3gYtm5K94vA== -"@cdklabs/typewriter@^0.0.5": - version "0.0.5" - resolved "https://registry.npmjs.org/@cdklabs/typewriter/-/typewriter-0.0.5.tgz#edbec5c2e6dd45c803154d7e521ca38746a08d89" - integrity sha512-gLp7s9bhHOIN9SN6jhdVi3cLp0YisMkvn4Ct3KeqySR7H1Q5nytKvV0NWUC1FrdNsPoKvulUFIGtqbwCFZt9NQ== +"@cdklabs/typewriter@^0.0.6": + version "0.0.6" + resolved "https://registry.npmjs.org/@cdklabs/typewriter/-/typewriter-0.0.6.tgz#4f70afd8e70761785f09bfbee9b4678ffd6413a1" + integrity sha512-uOKGbxFG+J1ig2Cdn24d8wlELcS2MF6Z0Fsfawx+MiYLnRZJ3kgSZExui+QSm7PyXARW5rcpB+ZGh2maGNQbKw== "@colors/colors@1.6.0", "@colors/colors@^1.6.0": version "1.6.0" From 06626050576aa865ff5a26742ebc4529f49f8842 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:21:40 +0100 Subject: [PATCH 07/10] Update snapshot --- .../test/__snapshots__/resources.test.ts.snap | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap b/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap index cbef062beb1d4..a7e0b9781aef8 100644 --- a/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap +++ b/tools/@aws-cdk/spec2cdk/test/__snapshots__/resources.test.ts.snap @@ -1264,10 +1264,25 @@ export class CfnResource extends cdk.CfnResource implements cdk.IInspectable, IR } /** - * Creates a new IResourceRef from an ARN - */ - public static fromResourceArn(scope: constructs.Construct, id: string, arn: string): IResourceRef { - return new CfnResource.ImportArn(scope, id, arn); + * Creates a new IResourceRef from a resourceId + */ + public static fromResourceId(scope: constructs.Construct, id: string, resourceId: string): IResourceRef { + class Import extends cdk.Resource { + public resourceRef: ResourceReference; + + /** + * @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, resourceId: string) { + super(scope, id); + + this.resourceRef = { + "resourceId": resourceId + }; + } + } + return new Import(scope, id, resourceId); } /** @@ -1316,21 +1331,6 @@ export class CfnResource extends cdk.CfnResource implements cdk.IInspectable, IR } } -export namespace CfnResource { - export class ImportArn extends cdk.Resource implements IResourceRef { - public resourceRef: ResourceReference; - - public constructor(scope: constructs.Construct, id: string, arn: string) { - super(scope, id); - - const variables = cfn_parse.TemplateStringParser.parse("arn:\${Partition}:some:\${Region}:\${Account}:resource/\${ResourceId}", arn); - this.resourceRef = { - "resourceId": variables['ResourceId'] - }; - } - } -} - /** * Properties for defining a \`CfnResource\` * From bbb798e177303e5e751fc3bb7e7ec75d6f5a3f71 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:10:04 +0100 Subject: [PATCH 08/10] Add missing scope-map.json and index.ts --- packages/aws-cdk-lib/index.ts | 1 + packages/aws-cdk-lib/scripts/scope-map.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/aws-cdk-lib/index.ts b/packages/aws-cdk-lib/index.ts index 63c08856f998b..4784a49017e3f 100644 --- a/packages/aws-cdk-lib/index.ts +++ b/packages/aws-cdk-lib/index.ts @@ -262,6 +262,7 @@ export * as aws_ses_actions from './aws-ses-actions'; export * as aws_shield from './aws-shield'; export * as aws_signer from './aws-signer'; export * as aws_simspaceweaver from './aws-simspaceweaver'; +export * as aws_smsvoice from './aws-smsvoice'; export * as aws_sns from './aws-sns'; export * as aws_sns_subscriptions from './aws-sns-subscriptions'; export * as aws_sqs from './aws-sqs'; diff --git a/packages/aws-cdk-lib/scripts/scope-map.json b/packages/aws-cdk-lib/scripts/scope-map.json index 1dc9bdb421c4a..fad9715b31945 100644 --- a/packages/aws-cdk-lib/scripts/scope-map.json +++ b/packages/aws-cdk-lib/scripts/scope-map.json @@ -714,6 +714,9 @@ "aws-simspaceweaver": [ "AWS::SimSpaceWeaver" ], + "aws-smsvoice": [ + "AWS::SMSVOICE" + ], "aws-sns": [ "AWS::SNS" ], From 63a9a10f36356fae3bef3df38d74d680e4588e32 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:39:57 +0100 Subject: [PATCH 09/10] scope-map again --- packages/aws-cdk-lib/scripts/scope-map.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/aws-cdk-lib/scripts/scope-map.json b/packages/aws-cdk-lib/scripts/scope-map.json index da1973370905e..305c6c3b093bd 100644 --- a/packages/aws-cdk-lib/scripts/scope-map.json +++ b/packages/aws-cdk-lib/scripts/scope-map.json @@ -1195,7 +1195,9 @@ } ], "aws-smsvoice": [ - "AWS::SMSVOICE" + { + "namespace": "AWS::SMSVOICE" + } ], "aws-sns": [ { From ab76e11724f2d0a764ff109eece55b6fb5e3dc08 Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:37:43 +0100 Subject: [PATCH 10/10] - TemplateStringParser -> TemplateString - environmentFromArn - storing stackOfScope --- .../aws-dynamodb/test/dynamodb.test.ts | 12 +++++ .../core/lib/helpers-internal/strings.ts | 28 +++++++---- .../test/helpers-internal/strings.test.ts | 46 +++++++++---------- tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts | 2 +- .../spec2cdk/lib/cdk/resource-class.ts | 26 +++++++---- 5 files changed, 69 insertions(+), 45 deletions(-) diff --git a/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts b/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts index c029439f552b8..b3b05fc391de5 100644 --- a/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts +++ b/packages/aws-cdk-lib/aws-dynamodb/test/dynamodb.test.ts @@ -403,6 +403,12 @@ describe('L1 static factory methods', () => { const table = CfnTable.fromTableArn(stack, 'MyBucket', 'arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable'); expect(table.tableRef.tableName).toEqual('MyTable'); expect(table.tableRef.tableArn).toEqual('arn:aws:dynamodb:eu-west-1:123456789012:table/MyTable'); + + const env = stack.resolve((table as unknown as Resource).env); + expect(env).toEqual({ + region: 'eu-west-1', + account: '123456789012', + }); }); test('fromTableName', () => { @@ -426,6 +432,12 @@ describe('L1 static factory methods', () => { expect(stack.resolve(arnComponents.partition)).toEqual({ Ref: 'AWS::Partition', }); + + const env = stack.resolve((table as unknown as Resource).env); + expect(env).toEqual({ + region: 'us-east-1', + account: '23432424', + }); }); }); diff --git a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts index b004ddaaf3c1c..935571e39294a 100644 --- a/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts +++ b/packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts @@ -1,18 +1,20 @@ import { UnscopedValidationError } from '../errors'; /** - * Utility class for parsing template strings with variables. + * A string with variables in the form `${name}`. */ -export class TemplateStringParser { +export class TemplateString { + constructor(private readonly template: string) { + } + /** * Parses a template string with variables in the form of `${var}` and extracts the values from the input string. * Returns a record mapping variable names to their corresponding values. - * @param template the template string containing variables * @param input the input string to parse * @throws UnscopedValidationError if the input does not match the template */ - public static parse(template: string, input: string): Record { - const templateParts = template.split(/(\$\{[^{}]+})/); + public parse(input: string): Record { + const templateParts = this.template.split(/(\$\{[^{}]+})/); const result: Record = {}; let inputIndex = 0; @@ -27,7 +29,7 @@ export class TemplateStringParser { if (nextLiteral) { const endIndex = input.indexOf(nextLiteral, inputIndex); if (endIndex === -1) { - throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`); } value = input.slice(inputIndex, endIndex); inputIndex = endIndex; @@ -39,21 +41,27 @@ export class TemplateStringParser { result[varName] = value; } else { if (input.slice(inputIndex, inputIndex + part.length) !== part) { - throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`); } inputIndex += part.length; } } if (inputIndex !== input.length) { - throw new UnscopedValidationError(`Input ${input} does not match template ${template}`); + throw new UnscopedValidationError(`Input ${input} does not match template ${this.template}`); } return result; } - public static interpolate(template: string, variables: Record): string { - return template.replace(/\${([^{}]+)}/g, (_, varName) => { + /** + * Returns the template interpolated with the attributes of an object passed as input. + * Attributes that don't match any variable in the template are ignored, but all template + * variables must be replaced. + * @param variables an object where keys are the variable names, and values are the values to be replaced. + */ + public interpolate(variables: Record): string { + return this.template.replace(/\${([^{}]+)}/g, (_, varName) => { if (variables[varName] === undefined) { throw new UnscopedValidationError(`Variable ${varName} not provided for template interpolation`); } diff --git a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts index a13391e9124ac..4ed211e6dc37f 100644 --- a/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts +++ b/packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts @@ -1,66 +1,64 @@ import { UnscopedValidationError } from '../../lib'; -import { TemplateStringParser } from '../../lib/helpers-internal/strings'; +import { TemplateString } from '../../lib/helpers-internal'; -describe('TemplateStringParser', () => { +describe('new TemplateString', () => { describe('parse', () => { it('parses template with single variable correctly', () => { - const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!'); + const result = new TemplateString('Hello, ${name}!').parse('Hello, John!'); expect(result).toEqual({ name: 'John' }); }); it('parses template with multiple variables correctly', () => { - const result = TemplateStringParser.parse('My name is ${firstName} ${lastName}.', 'My name is Jane Doe.'); + const result = new TemplateString('My name is ${firstName} ${lastName}.').parse('My name is Jane Doe.'); expect(result).toEqual({ firstName: 'Jane', lastName: 'Doe' }); }); it('throws error when input does not match template', () => { expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hi, John!'); + new TemplateString('Hello, ${name}!').parse('Hi, John!'); }).toThrow(UnscopedValidationError); }); it('parses template with no variables correctly', () => { - const result = TemplateStringParser.parse('Hello, world!', 'Hello, world!'); + const result = new TemplateString('Hello, world!').parse('Hello, world!'); expect(result).toEqual({}); }); it('parses template with trailing variable correctly', () => { - const result = TemplateStringParser.parse('Path: ${path}', 'Path: /home/user'); + const result = new TemplateString('Path: ${path}').parse('Path: /home/user'); expect(result).toEqual({ path: '/home/user' }); }); it('throws error when input has extra characters', () => { expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hello, John!!'); + new TemplateString('Hello, ${name}!').parse('Hello, John!!'); }).toThrow(UnscopedValidationError); }); it('parses template with adjacent variables correctly', () => { - const result = TemplateStringParser.parse('${greeting}, ${name}!', 'Hi, John!'); + const result = new TemplateString('${greeting}, ${name}!').parse('Hi, John!'); expect(result).toEqual({ greeting: 'Hi', name: 'John' }); }); it('throws error when input is shorter than template', () => { expect(() => { - TemplateStringParser.parse('Hello, ${name}!', 'Hello, '); + new TemplateString('Hello, ${name}!').parse('Hello, '); }).toThrow(UnscopedValidationError); }); it('parses template with empty variable value correctly', () => { - const result = TemplateStringParser.parse('Hello, ${name}!', 'Hello, !'); + const result = new TemplateString('Hello, ${name}!').parse('Hello, !'); expect(result).toEqual({ name: '' }); }); it('parses template with variable at the start correctly', () => { - const result = TemplateStringParser.parse('${greeting}, world!', 'Hi, world!'); + const result = new TemplateString('${greeting}, world!').parse('Hi, world!'); expect(result).toEqual({ greeting: 'Hi' }); }); it('parses complex template correctly', () => { - const result = TemplateStringParser.parse( - 'arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}', - 'arn:aws:dynamodb:us-east-1:12345:table/MyTable', - ); + const result = new TemplateString('arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}') + .parse('arn:aws:dynamodb:us-east-1:12345:table/MyTable'); expect(result).toEqual({ Partition: 'aws', Region: 'us-east-1', @@ -72,12 +70,12 @@ describe('TemplateStringParser', () => { describe('interpolate', () => { it('interpolates template with single variable correctly', () => { - const result = TemplateStringParser.interpolate('Hello, ${name}!', { name: 'John' }); + const result = new TemplateString('Hello, ${name}!').interpolate({ name: 'John' }); expect(result).toBe('Hello, John!'); }); it('interpolates template with multiple variables correctly', () => { - const result = TemplateStringParser.interpolate('My name is ${firstName} ${lastName}.', { + const result = new TemplateString('My name is ${firstName} ${lastName}.').interpolate({ firstName: 'Jane', lastName: 'Doe', }); @@ -86,33 +84,33 @@ describe('TemplateStringParser', () => { it('throws error when variable is missing in interpolation', () => { expect(() => { - TemplateStringParser.interpolate('Hello, ${name}!', {}); + new TemplateString('Hello, ${name}!').interpolate({}); }).toThrow(UnscopedValidationError); }); it('interpolates template with no variables correctly', () => { - const result = TemplateStringParser.interpolate('Hello, world!', {}); + const result = new TemplateString('Hello, world!').interpolate({}); expect(result).toBe('Hello, world!'); }); it('throws error when template contains undefined variable', () => { expect(() => { - TemplateStringParser.interpolate('Hello, ${name}!', { greeting: 'Hi' }); + new TemplateString('Hello, ${name}!').interpolate({ greeting: 'Hi' }); }).toThrow(UnscopedValidationError); }); it('interpolates template with adjacent variables correctly', () => { - const result = TemplateStringParser.interpolate('${greeting}, ${name}!', { greeting: 'Hi', name: 'John' }); + const result = new TemplateString('${greeting}, ${name}!').interpolate({ greeting: 'Hi', name: 'John' }); expect(result).toBe('Hi, John!'); }); it('interpolates template with empty variable value correctly', () => { - const result = TemplateStringParser.interpolate('Hello, ${name}!', { name: '' }); + const result = new TemplateString('Hello, ${name}!').interpolate({ name: '' }); expect(result).toBe('Hello, !'); }); it('interpolates template with variable at the start correctly', () => { - const result = TemplateStringParser.interpolate('${greeting}, world!', { greeting: 'Hi' }); + const result = new TemplateString('${greeting}, world!').interpolate({ greeting: 'Hi' }); expect(result).toBe('Hi, world!'); }); }); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts index e6331ee799962..60074230f0cdc 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts @@ -94,7 +94,7 @@ export class CdkInternalHelpers extends ExternalModule { public readonly FromCloudFormationResult = $T(Type.fromName(this, 'FromCloudFormationResult')); public readonly FromCloudFormation = $T(Type.fromName(this, 'FromCloudFormation')); public readonly FromCloudFormationPropertyObject = Type.fromName(this, 'FromCloudFormationPropertyObject'); - public readonly TemplateStringParser = Type.fromName(this, 'TemplateStringParser'); + public readonly TemplateString = Type.fromName(this, 'TemplateString'); constructor(parent: CdkCore) { super(`${parent.fqn}/core/lib/helpers-internal`); diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts index b2e053fa29ce3..3e08ed49323df 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts @@ -297,13 +297,15 @@ export class ResourceClass extends ClassType { factory.addParameter({ name: 'arn', type: Type.STRING }); const initBodyStatements: Statement[] = [ - new SuperInitializer(_scope, id), + new SuperInitializer(_scope, id, expr.object({ + environmentFromArn: arn, + })), stmt.sep(), ]; if (hasNonArnProps) { initBodyStatements.push( - stmt.constVar(variables, $T(CDK_CORE.helpers.TemplateStringParser).parse(expr.lit(arnTemplate), arn)), + stmt.constVar(variables, CDK_CORE.helpers.TemplateString.newInstance(expr.lit(arnTemplate)).prop('parse').call(arn)), ); } initBodyStatements.push(stmt.assign($this[refAttributeName], expr.object(referenceObject))); @@ -360,10 +362,11 @@ export class ResourceClass extends ClassType { type: Type.STRING, }); - const interpolateArn = $T(CDK_CORE.helpers.TemplateStringParser).interpolate(expr.lit(arnTemplate), expr.object({ - Partition: $T(CDK_CORE.Stack).of(_scope).prop('partition'), - Region: $T(CDK_CORE.Stack).of(_scope).prop('region'), - Account: $T(CDK_CORE.Stack).of(_scope).prop('account'), + const stackOfScope = $T(CDK_CORE.Stack).of(_scope); + const interpolateArn = CDK_CORE.helpers.TemplateString.newInstance(expr.lit(arnTemplate)).prop('interpolate').call(expr.object({ + Partition: stackOfScope.prop('partition'), + Region: stackOfScope.prop('region'), + Account: stackOfScope.prop('account'), [variableName]: name, })); @@ -371,17 +374,20 @@ export class ResourceClass extends ClassType { [propName]: name, }; - const initBodyStatements: Statement[] = [ - new SuperInitializer(_scope, id), - stmt.sep(), - ]; + const initBodyStatements: Statement[] = []; const arnPropName = this.referenceStruct.properties.map(p => p.name).find(n => n.endsWith('Arn')); const arn = expr.ident('arn'); if (arnPropName != null) { refenceObject[arnPropName] = arn; initBodyStatements.push(stmt.constVar(arn, interpolateArn)); + initBodyStatements.push(new SuperInitializer(_scope, id, expr.object({ + environmentFromArn: arn, + }))); + } else { + initBodyStatements.push(new SuperInitializer(_scope, id)); } + initBodyStatements.push(stmt.sep()); initBodyStatements.push(stmt.assign($this[refAttributeName], expr.object(refenceObject))); init.addBody(...initBodyStatements);