Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Fn.findInMap supports default value #26543

Merged
merged 18 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
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
22 changes: 22 additions & 0 deletions packages/aws-cdk-lib/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,28 @@ declare const regionTable: CfnMapping;
regionTable.findInMap(Aws.REGION, 'regionName');
```

An optional default value can also be passed to `findInMap`. If either key is not found in the map and the mapping is lazy, `findInMap` will return the default value. If the mapping is not lazy or either key is an unresolved token, the call to `findInMap` will return a token that resolves to `{ "Fn::FindInMap": [ "MapName", "TopLevelKey", "SecondLevelKey", { "DefaultValue": "DefaultValue" } ] }`.

For example, the following code will again not produce anything in the "Mappings" section. The
call to `findInMap` will be able to resolve the value during synthesis and simply return
`'Region not found'`.

```ts
const regionTable = new CfnMapping(this, 'RegionTable', {
mapping: {
'us-east-1': {
regionName: 'US East (N. Virginia)',
},
'us-east-2': {
regionName: 'US East (Ohio)',
},
},
lazy: true,
});

regionTable.findInMap('us-west-1', 'regionName', 'Region not found');
```

[cfn-mappings]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html

### Dynamic References
Expand Down
13 changes: 7 additions & 6 deletions packages/aws-cdk-lib/core/lib/cfn-fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ export class Fn {
* keys in a two-level map that is declared in the Mappings section.
* @returns a token represented as a string
*/
public static findInMap(mapName: string, topLevelKey: string, secondLevelKey: string): string {
return Fn._findInMap(mapName, topLevelKey, secondLevelKey).toString();
public static findInMap(mapName: string, topLevelKey: string, secondLevelKey: string, defaultValue?: string): string {
return Fn._findInMap(mapName, topLevelKey, secondLevelKey, defaultValue).toString();
}

/**
Expand All @@ -245,8 +245,8 @@ export class Fn {
*
* @internal
*/
public static _findInMap(mapName: string, topLevelKey: string, secondLevelKey: string): IResolvable {
return new FnFindInMap(mapName, topLevelKey, secondLevelKey);
public static _findInMap(mapName: string, topLevelKey: string, secondLevelKey: string, defaultValue?: string): IResolvable {
return new FnFindInMap(mapName, topLevelKey, secondLevelKey, defaultValue);
}

/**
Expand Down Expand Up @@ -500,9 +500,10 @@ class FnFindInMap extends FnBase {
* @param mapName The logical name of a mapping declared in the Mappings section that contains the keys and values.
* @param topLevelKey The top-level key name. Its value is a list of key-value pairs.
* @param secondLevelKey The second-level key name, which is set to one of the keys from the list assigned to TopLevelKey.
* @param defaultValue The value of the default value returned if either the key is not found in the map
kaizencc marked this conversation as resolved.
Show resolved Hide resolved
*/
constructor(mapName: string, topLevelKey: any, secondLevelKey: any) {
super('Fn::FindInMap', [mapName, topLevelKey, secondLevelKey]);
constructor(mapName: string, topLevelKey: any, secondLevelKey: any, defaultValue?: string) {
super('Fn::FindInMap', [mapName, topLevelKey, secondLevelKey, defaultValue !== undefined ? { DefaultValue: defaultValue } : undefined]);
}
}

Expand Down
47 changes: 31 additions & 16 deletions packages/aws-cdk-lib/core/lib/cfn-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export interface CfnMappingProps {
export class CfnMapping extends CfnRefElement {
private mapping: Mapping;
private readonly lazy?: boolean;
private lazyRender = false;
private lazyInformed = false;
private lazyRender = false; // prescribes `_toCloudFormation()` to pass nothing if value from map is returned lazily.
private lazyInformed = false; // keeps track if user has been sent a message informing them of the possibility to use lazy synthesis.

constructor(scope: Construct, id: string, props: CfnMappingProps = {}) {
super(scope, id);
Expand All @@ -63,30 +63,41 @@ export class CfnMapping extends CfnRefElement {
}

/**
* @returns A reference to a value in the map based on the two keys.
* @returns A reference to a value in the map based on the two keys. If mapping is lazy, the value from the map or default value is returned instead of the reference.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure "lazy" means: only render the actual mapping to the template if there is at least one value that actually needs to be looked up from it at deploy time.

Conversely: if no value is ever read from the map, or all values can be resolved at synth time, then there is no need to render the map.

The line of documentation you added here doesn't help me understand that :).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Felt that it wasn't accurate to say it returns a reference is sometimes it just passes a value. Also could help debug if the short circuit was not documented. Can add a line about the mapping behavior too

*/
public findInMap(key1: string, key2: string): string {
public findInMap(key1: string, key2: string, defaultValue?: string): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohmigod. Can this method be simplified if we reorganize it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be glad to, but not seeing a clear way. Think we ship it and someone enterprising can tidy it up later.

let fullyResolved = false;
let notInMap = false;
if (!Token.isUnresolved(key1)) {
if (!(key1 in this.mapping)) {
throw new Error(`Mapping doesn't contain top-level key '${key1}'`);
}
if (!Token.isUnresolved(key2)) {
if (defaultValue === undefined) {
throw new Error(`Mapping doesn't contain top-level key '${key1}'`);
} else {
notInMap = true;
}
} else if (!Token.isUnresolved(key2)) {
if (!(key2 in this.mapping[key1])) {
throw new Error(`Mapping doesn't contain second-level key '${key2}'`);
if (defaultValue === undefined) {
throw new Error(`Mapping doesn't contain second-level key '${key2}'`);
} else {
notInMap = true;
}
}
fullyResolved = true;
}
}
if (fullyResolved) {
if (this.lazy) {

if (this.lazy) {
if (notInMap && defaultValue !== undefined) {
return defaultValue;
} else if (fullyResolved) {
return this.mapping[key1][key2];
}
} else {
this.lazyRender = true;
}

return new CfnMappingEmbedder(this, this.mapping, key1, key2).toString();
this.lazyRender = !fullyResolved;

return new CfnMappingEmbedder(this, this.mapping, key1, key2, defaultValue).toString();
}

/**
Expand Down Expand Up @@ -130,12 +141,16 @@ export class CfnMapping extends CfnRefElement {
class CfnMappingEmbedder implements IResolvable {
readonly creationStack: string[] = [];

constructor(private readonly cfnMapping: CfnMapping, readonly mapping: Mapping, private readonly key1: string, private readonly key2: string) { }
constructor(private readonly cfnMapping: CfnMapping,
readonly mapping: Mapping,
private readonly key1: string,
private readonly key2: string,
private readonly defaultValue?: string) { }

public resolve(context: IResolveContext): string {
const consumingStack = Stack.of(context.scope);
if (consumingStack === Stack.of(this.cfnMapping)) {
return Fn.findInMap(this.cfnMapping.logicalId, this.key1, this.key2);
return Fn.findInMap(this.cfnMapping.logicalId, this.key1, this.key2, this.defaultValue);
}

const constructScope = consumingStack;
Expand All @@ -148,7 +163,7 @@ class CfnMappingEmbedder implements IResolvable {
});
}

return Fn.findInMap(mappingCopy.logicalId, this.key1, this.key2);
return Fn.findInMap(mappingCopy.logicalId, this.key1, this.key2, this.defaultValue);
}

public toString() {
Expand Down
117 changes: 116 additions & 1 deletion packages/aws-cdk-lib/core/test/mappings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe('lazy mapping', () => {
});
});

it('throws if keys can be resolved but are not found in backing', () => {
it('throws if keys can be resolved but are not found in mapping', () => {
expect(() => mapping.findInMap('NonExistentKey', 'SecondLevelKey1'))
.toThrowError(/Mapping doesn't contain top-level key .*/);
expect(() => mapping.findInMap('TopLevelKey1', 'NonExistentKey'))
Expand Down Expand Up @@ -316,6 +316,121 @@ describe('eager by default', () => {
});
});

describe('defaultValue included', () => {
const backing = {
TopLevelKey1: {
SecondLevelKey1: [1, 2, 3],
SecondLevelKey2: { Hello: 'World' },
},
};

const defValue = 'foo';

let app: App;
let stack: Stack;
let mapping: CfnMapping;

describe('lazy mapping', () => {
beforeEach(() => {
stack = new Stack();
mapping = new CfnMapping(stack, 'Lazy Mapping', {
mapping: backing,
lazy: true,
});
});

it('does not create CfnMapping if findInMap keys can be resolved', () => {
const retrievedValue = mapping.findInMap('TopLevelKey1', 'SecondLevelKey1', defValue);

expect(stack.resolve(retrievedValue)).toStrictEqual([1, 2, 3]);
expect(toCloudFormation(stack)).toStrictEqual({});
});

it('creates CfnMapping if top level key cannot be resolved', () => {
const retrievedValue = mapping.findInMap(Aws.REGION, 'SecondLevelKey1', defValue);

expect(stack.resolve(retrievedValue)).toStrictEqual({ 'Fn::FindInMap': ['LazyMapping', { Ref: 'AWS::Region' }, 'SecondLevelKey1', { DefaultValue: defValue }] }); // should I use string or variable here? variable works
expect(toCloudFormation(stack)).toStrictEqual({
Mappings: {
LazyMapping: backing,
},
});
});

it('creates CfnMapping if second level key cannot be resolved', () => {
const retrievedValue = mapping.findInMap('TopLevelKey1', Aws.REGION, defValue);

expect(stack.resolve(retrievedValue)).toStrictEqual({ 'Fn::FindInMap': ['LazyMapping', 'TopLevelKey1', { Ref: 'AWS::Region' }, { DefaultValue: defValue }] });
expect(toCloudFormation(stack)).toStrictEqual({
Mappings: {
LazyMapping: backing,
},
});
});

it('returns default value if keys can be resolved but are not found in mapping', () => {
const retrievedValue1 = mapping.findInMap('NonExistentKey', 'SecondLevelKey1', defValue);
const retrievedValue2 = mapping.findInMap('TopLevelKey1', 'NonExistentKey', defValue);

expect(stack.resolve(retrievedValue1)).toStrictEqual('foo');
expect(stack.resolve(retrievedValue2)).toStrictEqual('foo');

expect(toCloudFormation(stack)).toStrictEqual({});
});
});

describe('eager by default', () => {
beforeEach(() => {
app = new App();
stack = new Stack(app, 'Stack');
mapping = new CfnMapping(stack, 'Lazy Mapping', {
mapping: backing,
});
});

it('emits warning if every findInMap resolves immediately', () => {
mapping.findInMap('TopLevelKey1', 'SecondLevelKey1', defValue);

const assembly = app.synth();

expect(getInfoAnnotations(assembly)).toStrictEqual([{
path: '/Stack/Lazy Mapping',
message: 'Consider making this CfnMapping a lazy mapping by providing `lazy: true`: either no findInMap was called or every findInMap could be immediately resolved without using Fn::FindInMap',
}]);
});

it('does not emit warning if a findInMap could not resolve immediately', () => {
mapping.findInMap('TopLevelKey1', Aws.REGION, defValue);

const assembly = app.synth();

expect(getInfoAnnotations(assembly)).toStrictEqual([]);
});

it('creates CfnMapping if top level key cannot be resolved', () => {
const retrievedValue = mapping.findInMap(Aws.REGION, 'SecondLevelKey1', defValue);

expect(stack.resolve(retrievedValue)).toStrictEqual({ 'Fn::FindInMap': ['LazyMapping', { Ref: 'AWS::Region' }, 'SecondLevelKey1', { DefaultValue: defValue }] }); // should I use string or variable here? variable works
expect(toCloudFormation(stack)).toStrictEqual({
Mappings: {
LazyMapping: backing,
},
});
});

it('creates CfnMapping if second level key cannot be resolved', () => {
const retrievedValue = mapping.findInMap('TopLevelKey1', Aws.REGION, defValue);

expect(stack.resolve(retrievedValue)).toStrictEqual({ 'Fn::FindInMap': ['LazyMapping', 'TopLevelKey1', { Ref: 'AWS::Region' }, { DefaultValue: defValue }] });
expect(toCloudFormation(stack)).toStrictEqual({
Mappings: {
LazyMapping: backing,
},
});
});
});
});

function getInfoAnnotations(casm: CloudAssembly) {
const result = new Array<{ path: string, message: string }>();
for (const stack of Object.values(casm.manifest.artifacts ?? {})) {
Expand Down
Loading