1
1
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' ;
8
5
9
6
/**
10
7
* Routines that know how to do operations at the CloudFormation document language level
@@ -24,59 +21,12 @@ export class CloudFormationLang {
24
21
* @param space Indentation to use (default: no pretty-printing)
25
22
*/
26
23
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
68
24
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 ) ,
75
29
} ) ;
76
-
77
- function wrap ( value : any ) : any {
78
- return isIntrinsic ( value ) ? new JsonToken ( deepQuoteStringsForJSON ( value ) ) : value ;
79
- }
80
30
}
81
31
82
32
/**
@@ -97,44 +47,213 @@ export class CloudFormationLang {
97
47
98
48
// Otherwise return a Join intrinsic (already in the target document language to avoid taking
99
49
// circular dependencies on FnJoin & friends)
100
- return { 'Fn::Join' : [ '' , minimalCloudFormationJoin ( '' , parts ) ] } ;
50
+ return fnJoinConcat ( parts ) ;
101
51
}
102
52
}
103
53
104
54
/**
105
- * Token that also stringifies in the toJSON() operation.
55
+ * Return a CFN intrinsic mass concatting any number of CloudFormation expressions
106
56
*/
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 ) ] } ;
114
59
}
115
60
116
61
/**
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.
118
103
*/
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 ) ) ;
125
114
}
126
115
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 ) ) ;
129
135
}
130
136
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 ;
134
149
}
150
+ indent -= space ;
151
+ if ( atLeastOne ) { pushLineBreak ( ) ; }
152
+ pushLiteral ( post ) ;
135
153
}
136
154
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
+ }
138
257
}
139
258
140
259
const CLOUDFORMATION_CONCAT : IFragmentConcatenator = {
@@ -194,7 +313,7 @@ function isIntrinsic(x: any) {
194
313
const keys = Object . keys ( x ) ;
195
314
if ( keys . length !== 1 ) { return false ; }
196
315
197
- return keys [ 0 ] === 'Ref' || isNameOfCloudFormationIntrinsic ( keys [ 0 ] ) ;
316
+ return keys [ 0 ] === 'Ref' || isNameOfCloudFormationIntrinsic ( keys [ 0 ] ) || isNameOfCdkIntrinsic ( keys [ 0 ] ) ;
198
317
}
199
318
200
319
export function isNameOfCloudFormationIntrinsic ( name : string ) : boolean {
@@ -204,3 +323,63 @@ export function isNameOfCloudFormationIntrinsic(name: string): boolean {
204
323
// these are 'fake' intrinsics, only usable inside the parameter overrides of a CFN CodePipeline Action
205
324
return name !== 'Fn::GetArtifactAtt' && name !== 'Fn::GetParam' ;
206
325
}
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