Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/aws-cdk-lib/aws-s3/test/bucket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk-lib/core/lib/helpers-internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
54 changes: 54 additions & 0 deletions packages/aws-cdk-lib/core/lib/helpers-internal/strings.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const templateParts = template.split(/(\$\{[^}]+\})/);
const result: Record<string, string> = {};

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;
}
}
57 changes: 57 additions & 0 deletions packages/aws-cdk-lib/core/test/helpers-internal/strings.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
1 change: 1 addition & 0 deletions tools/@aws-cdk/spec2cdk/lib/cdk/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
142 changes: 126 additions & 16 deletions tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -137,6 +138,7 @@ export class ResourceClass extends ClassType {
});

this.makeFromCloudFormationFactory();
this.makeFromArnFactory();

if (this.resource.cloudFormationTransform) {
this.addProperty({
Expand Down Expand Up @@ -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: {
Expand All @@ -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.`,
Expand All @@ -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<string, Expression> = 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',
Expand Down Expand Up @@ -457,3 +535,35 @@ export class ResourceClass extends ClassType {
function isDefined<T>(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<string, string> {
const variables = extractVariables(template);
const result: Record<string, string> = {};

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));
}
Loading
Loading