Skip to content

Commit 53bf0b6

Browse files
committed
refactor(core): refactor CloudFormationLang.toJSON()
Our previous implementation of `toJSON()` was quite hacky. It replaced values inside the structure with objects that had a custom `toJSON()` serializer, and then called `JSON.stringify()` on the result. The resulting JSON would have special markers in it where the Token values would be string-substituted back in. It's actually easier and gives us more control to just implement JSONification ourselves in a Token-aware recursive function. This change has been split off from a larger, upcoming PR in order to make the individual reviews smaller.
1 parent a4a41b5 commit 53bf0b6

File tree

5 files changed

+442
-186
lines changed

5 files changed

+442
-186
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { Lazy } from '../lazy';
2-
import { Reference } from '../reference';
3-
import { DefaultTokenResolver, IFragmentConcatenator, IPostProcessor, IResolvable, IResolveContext } from '../resolvable';
4-
import { TokenizedStringFragments } from '../string-fragments';
5-
import { Token } from '../token';
6-
import { Intrinsic } from './intrinsic';
7-
import { resolve } from './resolve';
2+
import { DefaultTokenResolver, IFragmentConcatenator, IResolveContext } from '../resolvable';
3+
import { isResolvableObject, Token } from '../token';
4+
import { TokenMap } from './token-map';
85

96
/**
107
* Routines that know how to do operations at the CloudFormation document language level
@@ -24,59 +21,12 @@ export class CloudFormationLang {
2421
* @param space Indentation to use (default: no pretty-printing)
2522
*/
2623
public static toJSON(obj: any, space?: number): string {
27-
// This works in two stages:
28-
//
29-
// First, resolve everything. This gets rid of the lazy evaluations, evaluation
30-
// to the real types of things (for example, would a function return a string, an
31-
// intrinsic, or a number? We have to resolve to know).
32-
//
33-
// We then to through the returned result, identify things that evaluated to
34-
// CloudFormation intrinsics, and re-wrap those in Tokens that have a
35-
// toJSON() method returning their string representation. If we then call
36-
// JSON.stringify() on that result, that gives us essentially the same
37-
// string that we started with, except with the non-token characters quoted.
38-
//
39-
// {"field": "${TOKEN}"} --> {\"field\": \"${TOKEN}\"}
40-
//
41-
// A final resolve() on that string (done by the framework) will yield the string
42-
// we're after.
43-
//
44-
// Resolving and wrapping are done in go using the resolver framework.
45-
class IntrinsincWrapper extends DefaultTokenResolver {
46-
constructor() {
47-
super(CLOUDFORMATION_CONCAT);
48-
}
49-
50-
public resolveToken(t: IResolvable, context: IResolveContext, postProcess: IPostProcessor) {
51-
// Return References directly, so their type is maintained and the references will
52-
// continue to work. Only while preparing, because we do need the final value of the
53-
// token while resolving.
54-
if (Reference.isReference(t) && context.preparing) { return wrap(t); }
55-
56-
// Deep-resolve and wrap. This is necessary for Lazy tokens so we can see "inside" them.
57-
return wrap(super.resolveToken(t, context, postProcess));
58-
}
59-
public resolveString(fragments: TokenizedStringFragments, context: IResolveContext) {
60-
return wrap(super.resolveString(fragments, context));
61-
}
62-
public resolveList(l: string[], context: IResolveContext) {
63-
return wrap(super.resolveList(l, context));
64-
}
65-
}
66-
67-
// We need a ResolveContext to get started so return a Token
6824
return Lazy.stringValue({
69-
produce: (ctx: IResolveContext) =>
70-
JSON.stringify(resolve(obj, {
71-
preparing: ctx.preparing,
72-
scope: ctx.scope,
73-
resolver: new IntrinsincWrapper(),
74-
}), undefined, space),
25+
// We used to do this by hooking into `JSON.stringify()` by adding in objects
26+
// with custom `toJSON()` functions, but it's ultimately simpler just to
27+
// reimplement the `stringify()` function from scratch.
28+
produce: (ctx) => tokenAwareStringify(obj, space ?? 0, ctx),
7529
});
76-
77-
function wrap(value: any): any {
78-
return isIntrinsic(value) ? new JsonToken(deepQuoteStringsForJSON(value)) : value;
79-
}
8030
}
8131

8232
/**
@@ -97,44 +47,213 @@ export class CloudFormationLang {
9747

9848
// Otherwise return a Join intrinsic (already in the target document language to avoid taking
9949
// circular dependencies on FnJoin & friends)
100-
return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] };
50+
return fnJoinConcat(parts);
10151
}
10252
}
10353

10454
/**
105-
* Token that also stringifies in the toJSON() operation.
55+
* Return a CFN intrinsic mass concatting any number of CloudFormation expressions
10656
*/
107-
class JsonToken extends Intrinsic {
108-
/**
109-
* Special handler that gets called when JSON.stringify() is used.
110-
*/
111-
public toJSON() {
112-
return this.toString();
113-
}
57+
function fnJoinConcat(parts: any[]) {
58+
return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] };
11459
}
11560

11661
/**
117-
* Deep escape strings for use in a JSON context
62+
* Perform a JSON.stringify()-like operation, except aware of Tokens and CloudFormation intrincics
63+
*
64+
* Tokens will be resolved and if they resolve to CloudFormation intrinsics, the intrinsics
65+
* will be lifted to the top of a giant `{ Fn::Join}` expression.
66+
*
67+
* We are looking to do the following transforms:
68+
*
69+
* (a) Token in a string context
70+
*
71+
* { "field": "a${TOKEN}b" } -> "{ \"field\": \"a" ++ resolve(TOKEN) ++ "b\" }"
72+
* { "a${TOKEN}b": "value" } -> "{ \"a" ++ resolve(TOKEN) ++ "b\": \"value\" }"
73+
*
74+
* (b) Standalone token
75+
*
76+
* { "field": TOKEN } ->
77+
*
78+
* if TOKEN resolves to a string (or is a non-encoded or string-encoded intrinsic) ->
79+
* "{ \"field\": \"" ++ resolve(TOKEN) ++ "\" }"
80+
* if TOKEN resolves to a non-string (or is a non-string-encoded intrinsic) ->
81+
* "{ \"field\": " ++ resolve(TOKEN) ++ " }"
82+
*
83+
* (Where ++ is the CloudFormation string-concat operation (`{ Fn::Join }`).
84+
*
85+
* -------------------
86+
*
87+
* Here come complex type interpretation rules, which we are unable to simplify because
88+
* some clients our there are already taking dependencies on the unintended side effects
89+
* of the old implementation.
90+
*
91+
* 1. If TOKEN is embedded in a string with a prefix or postfix, we'll render the token
92+
* as a string regardless of whether it returns an intrinsic or a literal.
93+
*
94+
* 2. If TOKEN resolves to an intrinsic:
95+
* - We'll treat it as a string if the TOKEN itself was not encoded or string-encoded
96+
* (this covers the 99% case of what CloudFormation intrinsics resolve to).
97+
* - We'll treat it as a non-string otherwise; the intrinsic MUST resolve to a number;
98+
* * if resolves to a list { Fn::Join } will fail
99+
* * if it resolves to a string after all the JSON will be malformed at API call time.
100+
*
101+
* 3. Otherwise, the type of the value it resolves to (string, number, complex object, ...)
102+
* determines how the value is rendered.
118103
*/
119-
function deepQuoteStringsForJSON(x: any): any {
120-
if (typeof x === 'string') {
121-
// Whenever we escape a string we strip off the outermost quotes
122-
// since we're already in a quoted context.
123-
const stringified = JSON.stringify(x);
124-
return stringified.substring(1, stringified.length - 1);
104+
function tokenAwareStringify(root: any, space: number, ctx: IResolveContext) {
105+
let indent = 0;
106+
107+
const ret = new Array<Segment>();
108+
recurse(root);
109+
switch (ret.length) {
110+
case 0: return '';
111+
case 1: return renderSegment(ret[0]);
112+
default:
113+
return fnJoinConcat(ret.map(renderSegment));
125114
}
126115

127-
if (Array.isArray(x)) {
128-
return x.map(deepQuoteStringsForJSON);
116+
/**
117+
* Stringify a JSON element
118+
*/
119+
function recurse(obj: any): void {
120+
if (Token.isUnresolved(obj)) {
121+
return handleToken(obj);
122+
}
123+
if (Array.isArray(obj)) {
124+
return renderCollection('[', ']', obj, recurse);
125+
}
126+
if (typeof obj === 'object' && obj != null && !(obj instanceof Date)) {
127+
return renderCollection('{', '}', definedEntries(obj), ([key, value]) => {
128+
recurse(key);
129+
pushLiteral(prettyPunctuation(':'));
130+
recurse(value);
131+
});
132+
}
133+
// Otherwise we have a scalar, defer to JSON.stringify()s serialization
134+
pushLiteral(JSON.stringify(obj));
129135
}
130136

131-
if (typeof x === 'object') {
132-
for (const key of Object.keys(x)) {
133-
x[key] = deepQuoteStringsForJSON(x[key]);
137+
/**
138+
* Render an object or list
139+
*/
140+
function renderCollection<A>(pre: string, post: string, xs: Iterable<A>, each: (x: A) => void) {
141+
pushLiteral(pre);
142+
indent += space;
143+
let atLeastOne = false;
144+
for (const [comma, item] of sepIter(xs)) {
145+
if (comma) { pushLiteral(','); }
146+
pushLineBreak();
147+
each(item);
148+
atLeastOne = true;
134149
}
150+
indent -= space;
151+
if (atLeastOne) { pushLineBreak(); }
152+
pushLiteral(post);
135153
}
136154

137-
return x;
155+
/**
156+
* Handle a Token.
157+
*
158+
* Can be any of:
159+
*
160+
* - Straight up IResolvable
161+
* - Encoded string, number or list
162+
*/
163+
function handleToken(token: any) {
164+
if (typeof token === 'string') {
165+
// Encoded string, treat like a string if it has a token and at least one other
166+
// component, otherwise treat like a regular token and base the output quoting on the
167+
// type of the result.
168+
const fragments = TokenMap.instance().splitString(token);
169+
if (fragments.length > 1) {
170+
pushLiteral('"');
171+
fragments.visit({
172+
visitLiteral: pushLiteral,
173+
visitToken: (tok) => {
174+
const resolved = ctx.resolve(tok);
175+
if (isIntrinsic(resolved)) {
176+
pushIntrinsic(quoteInsideIntrinsic(resolved));
177+
} else {
178+
// We're already in a string context, so stringify and escape
179+
pushLiteral(quoteString(`${resolved}`));
180+
}
181+
},
182+
// This potential case is the result of poor modeling in the tokenized string, it should not happen
183+
visitIntrinsic: () => { throw new Error('Intrinsic not expected in a freshly-split string'); },
184+
});
185+
pushLiteral('"');
186+
return;
187+
}
188+
}
189+
190+
const resolved = ctx.resolve(token);
191+
if (isIntrinsic(resolved)) {
192+
if (isResolvableObject(token) || typeof token === 'string') {
193+
// If the input was an unencoded IResolvable or a string-encoded value,
194+
// treat it like it was a string (for the 99% case)
195+
pushLiteral('"');
196+
pushIntrinsic(quoteInsideIntrinsic(resolved));
197+
pushLiteral('"');
198+
} else {
199+
pushIntrinsic(resolved);
200+
}
201+
return;
202+
}
203+
204+
// Otherwise we got an arbitrary JSON structure from the token, recurse
205+
recurse(resolved);
206+
}
207+
208+
/**
209+
* Push a literal onto the current segment if it's also a literal, otherwise open a new Segment
210+
*/
211+
function pushLiteral(lit: string) {
212+
let last = ret[ret.length - 1];
213+
if (last?.type !== 'literal') {
214+
last = { type: 'literal', parts: [] };
215+
ret.push(last);
216+
}
217+
last.parts.push(lit);
218+
}
219+
220+
/**
221+
* Add a new intrinsic segment
222+
*/
223+
function pushIntrinsic(intrinsic: any) {
224+
ret.push({ type: 'intrinsic', intrinsic });
225+
}
226+
227+
/**
228+
* Push a line break if we are pretty-printing, otherwise don't
229+
*/
230+
function pushLineBreak() {
231+
if (space > 0) {
232+
pushLiteral(`\n${' '.repeat(indent)}`);
233+
}
234+
}
235+
236+
/**
237+
* Add a space after the punctuation if we are pretty-printing, no space if not
238+
*/
239+
function prettyPunctuation(punc: string) {
240+
return space > 0 ? `${punc} ` : punc;
241+
}
242+
}
243+
244+
/**
245+
* A Segment is either a literal string or a CloudFormation intrinsic
246+
*/
247+
type Segment = { type: 'literal'; parts: string[] } | { type: 'intrinsic'; intrinsic: any };
248+
249+
/**
250+
* Render a segment
251+
*/
252+
function renderSegment(s: Segment): NonNullable<any> {
253+
switch (s.type) {
254+
case 'literal': return s.parts.join('');
255+
case 'intrinsic': return s.intrinsic;
256+
}
138257
}
139258

140259
const CLOUDFORMATION_CONCAT: IFragmentConcatenator = {
@@ -194,7 +313,7 @@ function isIntrinsic(x: any) {
194313
const keys = Object.keys(x);
195314
if (keys.length !== 1) { return false; }
196315

197-
return keys[0] === 'Ref' || isNameOfCloudFormationIntrinsic(keys[0]);
316+
return keys[0] === 'Ref' || isNameOfCloudFormationIntrinsic(keys[0]) || isNameOfCdkIntrinsic(keys[0]);
198317
}
199318

200319
export function isNameOfCloudFormationIntrinsic(name: string): boolean {
@@ -204,3 +323,63 @@ export function isNameOfCloudFormationIntrinsic(name: string): boolean {
204323
// these are 'fake' intrinsics, only usable inside the parameter overrides of a CFN CodePipeline Action
205324
return name !== 'Fn::GetArtifactAtt' && name !== 'Fn::GetParam';
206325
}
326+
327+
export function isNameOfCdkIntrinsic(name: string): boolean {
328+
return name.startsWith('$Cdk::');
329+
}
330+
331+
/**
332+
* Separated iterator
333+
*/
334+
function* sepIter<A>(xs: Iterable<A>): IterableIterator<[boolean, A]> {
335+
let comma = false;
336+
for (const item of xs) {
337+
yield [comma, item];
338+
comma = true;
339+
}
340+
}
341+
342+
/**
343+
* Object.entries() but skipping undefined values
344+
*/
345+
function* definedEntries<A extends object>(xs: A): IterableIterator<[string, any]> {
346+
for (const [key, value] of Object.entries(xs)) {
347+
if (value !== undefined) {
348+
yield [key, value];
349+
}
350+
}
351+
}
352+
353+
/**
354+
* Quote string literals inside an intrinsic
355+
*/
356+
function quoteInsideIntrinsic(x: any): any {
357+
if (typeof x === 'object' && x != null && Object.keys(x).length === 1) {
358+
const key = Object.keys(x)[0];
359+
const params = x[key];
360+
switch (key) {
361+
case 'Fn::If':
362+
return { 'Fn::If': [params[0], quoteInsideIntrinsic(params[1]), quoteInsideIntrinsic(params[2])] };
363+
case 'Fn::Join':
364+
return { 'Fn::Join': [quoteInsideIntrinsic(params[0]), params[1].map(quoteInsideIntrinsic)] };
365+
case 'Fn::Sub':
366+
if (Array.isArray(params)) {
367+
return { 'Fn::Sub': [quoteInsideIntrinsic(params[0]), params[1]] };
368+
} else {
369+
return { 'Fn::Sub': quoteInsideIntrinsic(params[0]) };
370+
}
371+
}
372+
}
373+
if (typeof x === 'string') {
374+
return quoteString(x);
375+
}
376+
return x;
377+
}
378+
379+
/**
380+
* Quote the characters inside a string, for use inside toJSON
381+
*/
382+
function quoteString(s: string) {
383+
s = JSON.stringify(s);
384+
return s.substring(1, s.length - 1);
385+
}

0 commit comments

Comments
 (0)