diff --git a/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts b/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts index 4a74665b8f338..06b7b778e7116 100644 --- a/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts +++ b/packages/@aws-cdk/core/lib/private/cloudformation-lang.ts @@ -1,10 +1,7 @@ import { Lazy } from '../lazy'; -import { Reference } from '../reference'; -import { DefaultTokenResolver, IFragmentConcatenator, IPostProcessor, IResolvable, IResolveContext } from '../resolvable'; -import { TokenizedStringFragments } from '../string-fragments'; -import { Token } from '../token'; -import { Intrinsic } from './intrinsic'; -import { resolve } from './resolve'; +import { DefaultTokenResolver, IFragmentConcatenator, IResolveContext } from '../resolvable'; +import { isResolvableObject, Token } from '../token'; +import { TokenMap } from './token-map'; /** * Routines that know how to do operations at the CloudFormation document language level @@ -24,59 +21,12 @@ export class CloudFormationLang { * @param space Indentation to use (default: no pretty-printing) */ public static toJSON(obj: any, space?: number): string { - // This works in two stages: - // - // First, resolve everything. This gets rid of the lazy evaluations, evaluation - // to the real types of things (for example, would a function return a string, an - // intrinsic, or a number? We have to resolve to know). - // - // We then to through the returned result, identify things that evaluated to - // CloudFormation intrinsics, and re-wrap those in Tokens that have a - // toJSON() method returning their string representation. If we then call - // JSON.stringify() on that result, that gives us essentially the same - // string that we started with, except with the non-token characters quoted. - // - // {"field": "${TOKEN}"} --> {\"field\": \"${TOKEN}\"} - // - // A final resolve() on that string (done by the framework) will yield the string - // we're after. - // - // Resolving and wrapping are done in go using the resolver framework. - class IntrinsincWrapper extends DefaultTokenResolver { - constructor() { - super(CLOUDFORMATION_CONCAT); - } - - public resolveToken(t: IResolvable, context: IResolveContext, postProcess: IPostProcessor) { - // Return References directly, so their type is maintained and the references will - // continue to work. Only while preparing, because we do need the final value of the - // token while resolving. - if (Reference.isReference(t) && context.preparing) { return wrap(t); } - - // Deep-resolve and wrap. This is necessary for Lazy tokens so we can see "inside" them. - return wrap(super.resolveToken(t, context, postProcess)); - } - public resolveString(fragments: TokenizedStringFragments, context: IResolveContext) { - return wrap(super.resolveString(fragments, context)); - } - public resolveList(l: string[], context: IResolveContext) { - return wrap(super.resolveList(l, context)); - } - } - - // We need a ResolveContext to get started so return a Token return Lazy.stringValue({ - produce: (ctx: IResolveContext) => - JSON.stringify(resolve(obj, { - preparing: ctx.preparing, - scope: ctx.scope, - resolver: new IntrinsincWrapper(), - }), undefined, space), + // We used to do this by hooking into `JSON.stringify()` by adding in objects + // with custom `toJSON()` functions, but it's ultimately simpler just to + // reimplement the `stringify()` function from scratch. + produce: (ctx) => tokenAwareStringify(obj, space ?? 0, ctx), }); - - function wrap(value: any): any { - return isIntrinsic(value) ? new JsonToken(deepQuoteStringsForJSON(value)) : value; - } } /** @@ -97,44 +47,213 @@ export class CloudFormationLang { // Otherwise return a Join intrinsic (already in the target document language to avoid taking // circular dependencies on FnJoin & friends) - return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; + return fnJoinConcat(parts); } } /** - * Token that also stringifies in the toJSON() operation. + * Return a CFN intrinsic mass concatting any number of CloudFormation expressions */ -class JsonToken extends Intrinsic { - /** - * Special handler that gets called when JSON.stringify() is used. - */ - public toJSON() { - return this.toString(); - } +function fnJoinConcat(parts: any[]) { + return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; } /** - * Deep escape strings for use in a JSON context + * Perform a JSON.stringify()-like operation, except aware of Tokens and CloudFormation intrincics + * + * Tokens will be resolved and if they resolve to CloudFormation intrinsics, the intrinsics + * will be lifted to the top of a giant `{ Fn::Join}` expression. + * + * We are looking to do the following transforms: + * + * (a) Token in a string context + * + * { "field": "a${TOKEN}b" } -> "{ \"field\": \"a" ++ resolve(TOKEN) ++ "b\" }" + * { "a${TOKEN}b": "value" } -> "{ \"a" ++ resolve(TOKEN) ++ "b\": \"value\" }" + * + * (b) Standalone token + * + * { "field": TOKEN } -> + * + * if TOKEN resolves to a string (or is a non-encoded or string-encoded intrinsic) -> + * "{ \"field\": \"" ++ resolve(TOKEN) ++ "\" }" + * if TOKEN resolves to a non-string (or is a non-string-encoded intrinsic) -> + * "{ \"field\": " ++ resolve(TOKEN) ++ " }" + * + * (Where ++ is the CloudFormation string-concat operation (`{ Fn::Join }`). + * + * ------------------- + * + * Here come complex type interpretation rules, which we are unable to simplify because + * some clients our there are already taking dependencies on the unintended side effects + * of the old implementation. + * + * 1. If TOKEN is embedded in a string with a prefix or postfix, we'll render the token + * as a string regardless of whether it returns an intrinsic or a literal. + * + * 2. If TOKEN resolves to an intrinsic: + * - We'll treat it as a string if the TOKEN itself was not encoded or string-encoded + * (this covers the 99% case of what CloudFormation intrinsics resolve to). + * - We'll treat it as a non-string otherwise; the intrinsic MUST resolve to a number; + * * if resolves to a list { Fn::Join } will fail + * * if it resolves to a string after all the JSON will be malformed at API call time. + * + * 3. Otherwise, the type of the value it resolves to (string, number, complex object, ...) + * determines how the value is rendered. */ -function deepQuoteStringsForJSON(x: any): any { - if (typeof x === 'string') { - // Whenever we escape a string we strip off the outermost quotes - // since we're already in a quoted context. - const stringified = JSON.stringify(x); - return stringified.substring(1, stringified.length - 1); +function tokenAwareStringify(root: any, space: number, ctx: IResolveContext) { + let indent = 0; + + const ret = new Array(); + recurse(root); + switch (ret.length) { + case 0: return ''; + case 1: return renderSegment(ret[0]); + default: + return fnJoinConcat(ret.map(renderSegment)); } - if (Array.isArray(x)) { - return x.map(deepQuoteStringsForJSON); + /** + * Stringify a JSON element + */ + function recurse(obj: any): void { + if (Token.isUnresolved(obj)) { + return handleToken(obj); + } + if (Array.isArray(obj)) { + return renderCollection('[', ']', obj, recurse); + } + if (typeof obj === 'object' && obj != null && !(obj instanceof Date)) { + return renderCollection('{', '}', definedEntries(obj), ([key, value]) => { + recurse(key); + pushLiteral(prettyPunctuation(':')); + recurse(value); + }); + } + // Otherwise we have a scalar, defer to JSON.stringify()s serialization + pushLiteral(JSON.stringify(obj)); } - if (typeof x === 'object') { - for (const key of Object.keys(x)) { - x[key] = deepQuoteStringsForJSON(x[key]); + /** + * Render an object or list + */ + function renderCollection(pre: string, post: string, xs: Iterable, each: (x: A) => void) { + pushLiteral(pre); + indent += space; + let atLeastOne = false; + for (const [comma, item] of sepIter(xs)) { + if (comma) { pushLiteral(','); } + pushLineBreak(); + each(item); + atLeastOne = true; } + indent -= space; + if (atLeastOne) { pushLineBreak(); } + pushLiteral(post); } - return x; + /** + * Handle a Token. + * + * Can be any of: + * + * - Straight up IResolvable + * - Encoded string, number or list + */ + function handleToken(token: any) { + if (typeof token === 'string') { + // Encoded string, treat like a string if it has a token and at least one other + // component, otherwise treat like a regular token and base the output quoting on the + // type of the result. + const fragments = TokenMap.instance().splitString(token); + if (fragments.length > 1) { + pushLiteral('"'); + fragments.visit({ + visitLiteral: pushLiteral, + visitToken: (tok) => { + const resolved = ctx.resolve(tok); + if (isIntrinsic(resolved)) { + pushIntrinsic(quoteInsideIntrinsic(resolved)); + } else { + // We're already in a string context, so stringify and escape + pushLiteral(quoteString(`${resolved}`)); + } + }, + // This potential case is the result of poor modeling in the tokenized string, it should not happen + visitIntrinsic: () => { throw new Error('Intrinsic not expected in a freshly-split string'); }, + }); + pushLiteral('"'); + return; + } + } + + const resolved = ctx.resolve(token); + if (isIntrinsic(resolved)) { + if (isResolvableObject(token) || typeof token === 'string') { + // If the input was an unencoded IResolvable or a string-encoded value, + // treat it like it was a string (for the 99% case) + pushLiteral('"'); + pushIntrinsic(quoteInsideIntrinsic(resolved)); + pushLiteral('"'); + } else { + pushIntrinsic(resolved); + } + return; + } + + // Otherwise we got an arbitrary JSON structure from the token, recurse + recurse(resolved); + } + + /** + * Push a literal onto the current segment if it's also a literal, otherwise open a new Segment + */ + function pushLiteral(lit: string) { + let last = ret[ret.length - 1]; + if (last?.type !== 'literal') { + last = { type: 'literal', parts: [] }; + ret.push(last); + } + last.parts.push(lit); + } + + /** + * Add a new intrinsic segment + */ + function pushIntrinsic(intrinsic: any) { + ret.push({ type: 'intrinsic', intrinsic }); + } + + /** + * Push a line break if we are pretty-printing, otherwise don't + */ + function pushLineBreak() { + if (space > 0) { + pushLiteral(`\n${' '.repeat(indent)}`); + } + } + + /** + * Add a space after the punctuation if we are pretty-printing, no space if not + */ + function prettyPunctuation(punc: string) { + return space > 0 ? `${punc} ` : punc; + } +} + +/** + * A Segment is either a literal string or a CloudFormation intrinsic + */ +type Segment = { type: 'literal'; parts: string[] } | { type: 'intrinsic'; intrinsic: any }; + +/** + * Render a segment + */ +function renderSegment(s: Segment): NonNullable { + switch (s.type) { + case 'literal': return s.parts.join(''); + case 'intrinsic': return s.intrinsic; + } } const CLOUDFORMATION_CONCAT: IFragmentConcatenator = { @@ -194,7 +313,7 @@ function isIntrinsic(x: any) { const keys = Object.keys(x); if (keys.length !== 1) { return false; } - return keys[0] === 'Ref' || isNameOfCloudFormationIntrinsic(keys[0]); + return keys[0] === 'Ref' || isNameOfCloudFormationIntrinsic(keys[0]) || isNameOfCdkIntrinsic(keys[0]); } export function isNameOfCloudFormationIntrinsic(name: string): boolean { @@ -204,3 +323,63 @@ export function isNameOfCloudFormationIntrinsic(name: string): boolean { // these are 'fake' intrinsics, only usable inside the parameter overrides of a CFN CodePipeline Action return name !== 'Fn::GetArtifactAtt' && name !== 'Fn::GetParam'; } + +export function isNameOfCdkIntrinsic(name: string): boolean { + return name.startsWith('$Cdk::'); +} + +/** + * Separated iterator + */ +function* sepIter(xs: Iterable): IterableIterator<[boolean, A]> { + let comma = false; + for (const item of xs) { + yield [comma, item]; + comma = true; + } +} + +/** + * Object.entries() but skipping undefined values + */ +function* definedEntries(xs: A): IterableIterator<[string, any]> { + for (const [key, value] of Object.entries(xs)) { + if (value !== undefined) { + yield [key, value]; + } + } +} + +/** + * Quote string literals inside an intrinsic + */ +function quoteInsideIntrinsic(x: any): any { + if (typeof x === 'object' && x != null && Object.keys(x).length === 1) { + const key = Object.keys(x)[0]; + const params = x[key]; + switch (key) { + case 'Fn::If': + return { 'Fn::If': [params[0], quoteInsideIntrinsic(params[1]), quoteInsideIntrinsic(params[2])] }; + case 'Fn::Join': + return { 'Fn::Join': [quoteInsideIntrinsic(params[0]), params[1].map(quoteInsideIntrinsic)] }; + case 'Fn::Sub': + if (Array.isArray(params)) { + return { 'Fn::Sub': [quoteInsideIntrinsic(params[0]), params[1]] }; + } else { + return { 'Fn::Sub': quoteInsideIntrinsic(params[0]) }; + } + } + } + if (typeof x === 'string') { + return quoteString(x); + } + return x; +} + +/** + * Quote the characters inside a string, for use inside toJSON + */ +function quoteString(s: string) { + s = JSON.stringify(s); + return s.substring(1, s.length - 1); +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/private/token-map.ts b/packages/@aws-cdk/core/lib/private/token-map.ts index 1b4ea48c04440..c526a9b0e69cc 100644 --- a/packages/@aws-cdk/core/lib/private/token-map.ts +++ b/packages/@aws-cdk/core/lib/private/token-map.ts @@ -1,6 +1,6 @@ import { IResolvable } from '../resolvable'; import { TokenizedStringFragments } from '../string-fragments'; -import { Token } from '../token'; +import { isResolvableObject, Token } from '../token'; import { BEGIN_LIST_TOKEN_MARKER, BEGIN_STRING_TOKEN_MARKER, createTokenDouble, END_TOKEN_MARKER, extractTokenDouble, TokenString, VALID_KEY_CHARS, @@ -77,6 +77,7 @@ export class TokenMap { * Lookup a token from an encoded value */ public tokenFromEncoding(x: any): IResolvable | undefined { + if (isResolvableObject(x)) { return x; } if (typeof x === 'string') { return this.lookupString(x); } if (Array.isArray(x)) { return this.lookupList(x); } if (Token.isUnresolved(x)) { return x; } diff --git a/packages/@aws-cdk/core/lib/string-fragments.ts b/packages/@aws-cdk/core/lib/string-fragments.ts index b92fd3628a28d..81606bf7b4c8d 100644 --- a/packages/@aws-cdk/core/lib/string-fragments.ts +++ b/packages/@aws-cdk/core/lib/string-fragments.ts @@ -84,6 +84,25 @@ export class TokenizedStringFragments { return ret; } + /** + * Visit all fragments of the string + */ + public visit(visitor: ITokenVisitor) { + for (const f of this.fragments) { + switch (f.type) { + case 'literal': + visitor.visitLiteral(f.lit); + break; + case 'token': + visitor.visitToken(f.token); + break; + case 'intrinsic': + visitor.visitIntrinsic(f.value); + break; + } + } + } + /** * Combine the string fragments using the given joiner. * @@ -116,6 +135,28 @@ export interface ITokenMapper { mapToken(t: IResolvable): any; } +/** + * Interface to visit parts of an encoded token string + * + * Interface so it can be exported via jsii. + */ +export interface ITokenVisitor { + /** + * Visit a literal + */ + visitLiteral(lit: string): void; + + /** + * Visit a token + */ + visitToken(tok: IResolvable): void; + + /** + * Visit an intrinsic + */ + visitIntrinsic(intrinsic: any): void; +} + /** * Resolve the value from a single fragment * diff --git a/packages/@aws-cdk/core/test/cloudformation-json.test.ts b/packages/@aws-cdk/core/test/cloudformation-json.test.ts index 8d7e571501462..813bfdd27e102 100644 --- a/packages/@aws-cdk/core/test/cloudformation-json.test.ts +++ b/packages/@aws-cdk/core/test/cloudformation-json.test.ts @@ -1,12 +1,32 @@ -import { nodeunitShim, Test } from 'nodeunit-shim'; import { App, CfnOutput, Fn, Lazy, Stack, Token } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { evaluateCFN } from './evaluate-cfn'; -nodeunitShim({ - 'string tokens can be JSONified and JSONification can be reversed'(test: Test) { - const stack = new Stack(); +let app: App; +let stack: Stack; +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); +}); + +test('JSONification of literals looks like JSON.stringify', () => { + const structure = { + undefinedProp: undefined, + nestedObject: { + prop1: undefined, + prop2: 'abc', + prop3: 42, + prop4: [1, 2, 3], + }, + }; + + expect(stack.resolve(stack.toJsonString(structure))).toEqual(JSON.stringify(structure)); + expect(stack.resolve(stack.toJsonString(structure, 2))).toEqual(JSON.stringify(structure, undefined, 2)); +}); +describe('tokens that return literals', () => { + + test('string tokens can be JSONified and JSONification can be reversed', () => { for (const token of tokensThatResolveTo('woof woof')) { // GIVEN const fido = { name: 'Fido', speaks: token }; @@ -15,15 +35,11 @@ nodeunitShim({ const resolved = stack.resolve(stack.toJsonString(fido)); // THEN - test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"woof woof"}'); + expect(evaluateCFN(resolved)).toEqual('{"name":"Fido","speaks":"woof woof"}'); } + }); - test.done(); - }, - - 'string tokens can be embedded while being JSONified'(test: Test) { - const stack = new Stack(); - + test('string tokens can be embedded while being JSONified', () => { for (const token of tokensThatResolveTo('woof woof')) { // GIVEN const fido = { name: 'Fido', speaks: `deep ${token}` }; @@ -32,57 +48,93 @@ nodeunitShim({ const resolved = stack.resolve(stack.toJsonString(fido)); // THEN - test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"deep woof woof"}'); + expect(evaluateCFN(resolved)).toEqual('{"name":"Fido","speaks":"deep woof woof"}'); } + }); - test.done(); - }, - - 'constant string has correct amount of quotes applied'(test: Test) { - const stack = new Stack(); - + test('constant string has correct amount of quotes applied', () => { const inputString = 'Hello, "world"'; // WHEN const resolved = stack.resolve(stack.toJsonString(inputString)); // THEN - test.deepEqual(evaluateCFN(resolved), JSON.stringify(inputString)); + expect(evaluateCFN(resolved)).toEqual(JSON.stringify(inputString)); + }); - test.done(); - }, - - 'integer Tokens behave correctly in stringification and JSONification'(test: Test) { + test('integer Tokens behave correctly in stringification and JSONification', () => { // GIVEN - const stack = new Stack(); const num = new Intrinsic(1); const embedded = `the number is ${num}`; // WHEN - test.equal(evaluateCFN(stack.resolve(embedded)), 'the number is 1'); - test.equal(evaluateCFN(stack.resolve(stack.toJsonString({ embedded }))), '{"embedded":"the number is 1"}'); - test.equal(evaluateCFN(stack.resolve(stack.toJsonString({ num }))), '{"num":1}'); + expect(evaluateCFN(stack.resolve(embedded))).toEqual('the number is 1'); + expect(evaluateCFN(stack.resolve(stack.toJsonString({ embedded })))).toEqual('{"embedded":"the number is 1"}'); + expect(evaluateCFN(stack.resolve(stack.toJsonString({ num })))).toEqual('{"num":1}'); + }); + + test('String-encoded lazies do not have quotes applied if they return objects', () => { + // This is unfortunately crazy behavior, but we have some clients already taking a + // dependency on the fact that `Lazy.stringValue({ produce: () => [...some list...] })` + // does not apply quotes but just renders the list. + + // GIVEN + const someList = Lazy.stringValue({ produce: () => [1, 2, 3] as any }); + + // WHEN + expect(evaluateCFN(stack.resolve(stack.toJsonString({ someList })))).toEqual('{"someList":[1,2,3]}'); + }); + + test('List Tokens do not have quotes applied', () => { + // GIVEN + const someList = Token.asList([1, 2, 3]); - test.done(); - }, + // WHEN + expect(evaluateCFN(stack.resolve(stack.toJsonString({ someList })))).toEqual('{"someList":[1,2,3]}'); + }); - 'tokens in strings survive additional TokenJSON.stringification()'(test: Test) { + test('tokens in strings survive additional TokenJSON.stringification()', () => { // GIVEN - const stack = new Stack(); for (const token of tokensThatResolveTo('pong!')) { // WHEN const stringified = stack.toJsonString(`ping? ${token}`); // THEN - test.equal(evaluateCFN(stack.resolve(stringified)), '"ping? pong!"'); + expect(evaluateCFN(stack.resolve(stringified))).toEqual('"ping? pong!"'); } + }); + + test('Doubly nested strings evaluate correctly in JSON context', () => { + // WHEN + const fidoSays = Lazy.stringValue({ produce: () => 'woof' }); - test.done(); - }, + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + information: `Did you know that Fido says: ${fidoSays}`, + })); + + // THEN + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: woof"}'); + }); + + test('Quoted strings in embedded JSON context are escaped', () => { + // GIVEN + const fidoSays = Lazy.stringValue({ produce: () => '"woof"' }); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + information: `Did you know that Fido says: ${fidoSays}`, + })); + + // THEN + expect(evaluateCFN(resolved)).toEqual('{"information":"Did you know that Fido says: \\"woof\\""}'); + }); + +}); - 'intrinsic Tokens embed correctly in JSONification'(test: Test) { +describe('tokens returning CloudFormation intrinsics', () => { + test('intrinsic Tokens embed correctly in JSONification', () => { // GIVEN - const stack = new Stack(); const bucketName = new Intrinsic({ Ref: 'MyBucket' }); // WHEN @@ -90,13 +142,10 @@ nodeunitShim({ // THEN const context = { MyBucket: 'TheName' }; - test.equal(evaluateCFN(resolved, context), '{"theBucket":"TheName"}'); + expect(evaluateCFN(resolved, context)).toEqual('{"theBucket":"TheName"}'); + }); - test.done(); - }, - - 'fake intrinsics are serialized to objects'(test: Test) { - const stack = new Stack(); + test('fake intrinsics are serialized to objects', () => { const fakeIntrinsics = new Intrinsic({ a: { 'Fn::GetArtifactAtt': { @@ -112,16 +161,13 @@ nodeunitShim({ }); const stringified = stack.toJsonString(fakeIntrinsics); - test.equal(evaluateCFN(stack.resolve(stringified)), + expect(evaluateCFN(stack.resolve(stringified))).toEqual( '{"a":{"Fn::GetArtifactAtt":{"key":"val"}},"b":{"Fn::GetParam":["val1","val2"]}}'); + }); - test.done(); - }, - - 'embedded string literals in intrinsics are escaped when calling TokenJSON.stringify()'(test: Test) { + test('embedded string literals in intrinsics are escaped when calling TokenJSON.stringify()', () => { // GIVEN - const stack = new Stack(); - const token = Fn.join('', ['Hello', 'This\nIs', 'Very "cool"']); + const token = Fn.join('', ['Hello ', Token.asString({ Ref: 'Planet' }), ', this\nIs', 'Very "cool"']); // WHEN const resolved = stack.resolve(stack.toJsonString({ @@ -130,15 +176,13 @@ nodeunitShim({ })); // THEN - const expected = '{"literal":"I can also \\"contain\\" quotes","token":"HelloThis\\nIsVery \\"cool\\""}'; - test.equal(evaluateCFN(resolved), expected); + const context = { Planet: 'World' }; + const expected = '{"literal":"I can also \\"contain\\" quotes","token":"Hello World, this\\nIsVery \\"cool\\""}'; + expect(evaluateCFN(resolved, context)).toEqual(expected); + }); - test.done(); - }, - - 'Tokens in Tokens are handled correctly'(test: Test) { + test('Tokens in Tokens are handled correctly', () => { // GIVEN - const stack = new Stack(); const bucketName = new Intrinsic({ Ref: 'MyBucket' }); const combinedName = Fn.join('', ['The bucket name is ', bucketName.toString()]); @@ -147,31 +191,12 @@ nodeunitShim({ // THEN const context = { MyBucket: 'TheName' }; - test.equal(evaluateCFN(resolved, context), '{"theBucket":"The bucket name is TheName"}'); - - test.done(); - }, - - 'Doubly nested strings evaluate correctly in JSON context'(test: Test) { - // WHEN - const stack = new Stack(); - const fidoSays = Lazy.stringValue({ produce: () => 'woof' }); - - // WHEN - const resolved = stack.resolve(stack.toJsonString({ - information: `Did you know that Fido says: ${fidoSays}`, - })); + expect(evaluateCFN(resolved, context)).toEqual('{"theBucket":"The bucket name is TheName"}'); + }); - // THEN - test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: woof"}'); - - test.done(); - }, - - 'Doubly nested intrinsics evaluate correctly in JSON context'(test: Test) { + test('Doubly nested intrinsics evaluate correctly in JSON context', () => { // GIVEN - const stack = new Stack(); - const fidoSays = Lazy.anyValue({ produce: () => ({ Ref: 'Something' }) }); + const fidoSays = Lazy.anyValue({ produce: () => Token.asAny({ Ref: 'Something' }) }); // WHEN const resolved = stack.resolve(stack.toJsonString({ @@ -180,30 +205,11 @@ nodeunitShim({ // THEN const context = { Something: 'woof woof' }; - test.deepEqual(evaluateCFN(resolved, context), '{"information":"Did you know that Fido says: woof woof"}'); + expect(evaluateCFN(resolved, context)).toEqual('{"information":"Did you know that Fido says: woof woof"}'); + }); - test.done(); - }, - - 'Quoted strings in embedded JSON context are escaped'(test: Test) { + test('cross-stack references are also properly converted by toJsonString()', () => { // GIVEN - const stack = new Stack(); - const fidoSays = Lazy.stringValue({ produce: () => '"woof"' }); - - // WHEN - const resolved = stack.resolve(stack.toJsonString({ - information: `Did you know that Fido says: ${fidoSays}`, - })); - - // THEN - test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: \\"woof\\""}'); - - test.done(); - }, - - 'cross-stack references are also properly converted by toJsonString()'(test: Test) { - // GIVEN - const app = new App(); const stack1 = new Stack(app, 'Stack1'); const stack2 = new Stack(app, 'Stack2'); @@ -217,7 +223,7 @@ nodeunitShim({ // THEN const asm = app.synth(); - test.deepEqual(asm.getStackByName('Stack2').template, { + expect(asm.getStackByName('Stack2').template).toEqual({ Outputs: { Stack1Id: { Value: { @@ -232,9 +238,38 @@ nodeunitShim({ }, }, }); + }); - test.done(); - }, + test('Intrinsics can occur in key position', () => { + // GIVEN + const bucketName = Token.asString({ Ref: 'MyBucket' }); + + // WHEN + const resolved = stack.resolve(stack.toJsonString({ + [bucketName]: 'Is Cool', + [`${bucketName} Is`]: 'Cool', + })); + + // THEN + const context = { MyBucket: 'Harry' }; + expect(evaluateCFN(resolved, context)).toEqual('{"Harry":"Is Cool","Harry Is":"Cool"}'); + }); + + test('toJsonString() can be used recursively', () => { + // GIVEN + const bucketName = Token.asString({ Ref: 'MyBucket' }); + + // WHEN + const embeddedJson = stack.toJsonString({ message: `the bucket name is ${bucketName}` }); + const outerJson = stack.toJsonString({ embeddedJson }); + + // THEN + const evaluatedJson = evaluateCFN(stack.resolve(outerJson), { + MyBucket: 'Bucky', + }); + expect(evaluatedJson).toEqual('{"embeddedJson":"{\\"message\\":\\"the bucket name is Bucky\\"}"}'); + expect(JSON.parse(JSON.parse(evaluatedJson).embeddedJson).message).toEqual('the bucket name is Bucky'); + }); }); /** diff --git a/packages/@aws-cdk/core/test/evaluate-cfn.ts b/packages/@aws-cdk/core/test/evaluate-cfn.ts index 7dfa66328c319..ec8c3c7baff45 100644 --- a/packages/@aws-cdk/core/test/evaluate-cfn.ts +++ b/packages/@aws-cdk/core/test/evaluate-cfn.ts @@ -5,7 +5,7 @@ */ import { isNameOfCloudFormationIntrinsic } from '../lib/private/cloudformation-lang'; -export function evaluateCFN(object: any, context: {[key: string]: string} = {}): any { +export function evaluateCFN(object: any, context: {[key: string]: any} = {}): any { const intrinsics: any = { 'Fn::Join'(separator: string, args: string[]) { return args.map(evaluate).join(separator);