Skip to content

Commit cd7947c

Browse files
authored
feat(cdk): allow Tokens to be encoded as lists (#1144)
Add the ability for Tokens to be encoded into lists using the `token.toList()` method. This is useful for Tokens that intrinsically represent lists of strings, so we can pass them around in the type system as `string[]` (and interchange them with literal `string[]` values). The encoding is reversible just like string encodings are reversible. Contrary to strings encodings, concatenation operations are not allowed (they cannot be applied after decoding since CloudFormation does not have any list concatenation operators). This change does not change any CloudFormation resources yet to take advantage of the new encoding. Implements the most important remaining part of #744.
1 parent 2806d3a commit cd7947c

File tree

3 files changed

+205
-36
lines changed

3 files changed

+205
-36
lines changed

packages/@aws-cdk/cdk/lib/cloudformation/fn.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export class FnJoin extends Fn {
8787
private readonly listOfValues: any[];
8888
// Cache for the result of resolveValues() - since it otherwise would be computed several times
8989
private _resolvedValues?: any[];
90+
private canOptimize: boolean;
9091

9192
/**
9293
* Creates an ``Fn::Join`` function.
@@ -103,11 +104,13 @@ export class FnJoin extends Fn {
103104
super('Fn::Join', [ delimiter, new Token(() => this.resolveValues()) ]);
104105
this.delimiter = delimiter;
105106
this.listOfValues = listOfValues;
107+
this.canOptimize = true;
106108
}
107109

108110
public resolve(): any {
109-
if (this.resolveValues().length === 1) {
110-
return this.resolveValues()[0];
111+
const resolved = this.resolveValues();
112+
if (this.canOptimize && resolved.length === 1) {
113+
return resolved[0];
111114
}
112115
return super.resolve();
113116
}
@@ -120,6 +123,12 @@ export class FnJoin extends Fn {
120123
private resolveValues() {
121124
if (this._resolvedValues) { return this._resolvedValues; }
122125

126+
if (unresolved(this.listOfValues)) {
127+
// This is a list token, don't resolve and also don't optimize.
128+
this.canOptimize = false;
129+
return this._resolvedValues = this.listOfValues;
130+
}
131+
123132
const resolvedValues = [...this.listOfValues.map(e => resolve(e))];
124133
let i = 0;
125134
while (i < resolvedValues.length) {

packages/@aws-cdk/cdk/lib/core/tokens.ts

+112-33
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const RESOLVE_METHOD = 'resolve';
1717
* semantics.
1818
*/
1919
export class Token {
20-
private tokenKey?: string;
20+
private tokenStringification?: string;
21+
private tokenListification?: string[];
2122

2223
/**
2324
* Creates a token that resolves to `value`.
@@ -72,10 +73,10 @@ export class Token {
7273
return this.valueOrFunction.toString();
7374
}
7475

75-
if (this.tokenKey === undefined) {
76-
this.tokenKey = TOKEN_STRING_MAP.register(this, this.displayName);
76+
if (this.tokenStringification === undefined) {
77+
this.tokenStringification = TOKEN_MAP.registerString(this, this.displayName);
7778
}
78-
return this.tokenKey;
79+
return this.tokenStringification;
7980
}
8081

8182
/**
@@ -89,6 +90,30 @@ export class Token {
8990
throw new Error('JSON.stringify() cannot be applied to structure with a Token in it. Use a document-specific stringification method instead.');
9091
}
9192

93+
/**
94+
* Return a string list representation of this token
95+
*
96+
* Call this if the Token intrinsically evaluates to a list of strings.
97+
* If so, you can represent the Token in a similar way in the type
98+
* system.
99+
*
100+
* Note that even though the Token is represented as a list of strings, you
101+
* still cannot do any operations on it such as concatenation, indexing,
102+
* or taking its length. The only useful operations you can do to these lists
103+
* is constructing a `FnJoin` or a `FnSelect` on it.
104+
*/
105+
public toList(): string[] {
106+
const valueType = typeof this.valueOrFunction;
107+
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
108+
throw new Error('Got a literal Token value; cannot be encoded as a list.');
109+
}
110+
111+
if (this.tokenListification === undefined) {
112+
this.tokenListification = TOKEN_MAP.registerList(this, this.displayName);
113+
}
114+
return this.tokenListification;
115+
}
116+
92117
/**
93118
* Return a concated version of this Token in a string context
94119
*
@@ -103,12 +128,15 @@ export class Token {
103128

104129
/**
105130
* Returns true if obj is a token (i.e. has the resolve() method or is a string
106-
* that includes token markers).
131+
* that includes token markers), or it's a listifictaion of a Token string.
132+
*
107133
* @param obj The object to test.
108134
*/
109135
export function unresolved(obj: any): boolean {
110136
if (typeof(obj) === 'string') {
111-
return TOKEN_STRING_MAP.createTokenString(obj).test();
137+
return TOKEN_MAP.createStringTokenString(obj).test();
138+
} else if (Array.isArray(obj) && obj.length === 1) {
139+
return isListToken(obj[0]);
112140
} else {
113141
return typeof(obj[RESOLVE_METHOD]) === 'function';
114142
}
@@ -158,7 +186,7 @@ export function resolve(obj: any, prefix?: string[]): any {
158186
// string - potentially replace all stringified Tokens
159187
//
160188
if (typeof(obj) === 'string') {
161-
return TOKEN_STRING_MAP.resolveMarkers(obj as string);
189+
return TOKEN_MAP.resolveStringTokens(obj as string);
162190
}
163191

164192
//
@@ -169,27 +197,31 @@ export function resolve(obj: any, prefix?: string[]): any {
169197
return obj;
170198
}
171199

172-
//
173-
// tokens - invoke 'resolve' and continue to resolve recursively
174-
//
175-
176-
if (unresolved(obj)) {
177-
const value = obj[RESOLVE_METHOD]();
178-
return resolve(value, path);
179-
}
180-
181200
//
182201
// arrays - resolve all values, remove undefined and remove empty arrays
183202
//
184203

185204
if (Array.isArray(obj)) {
205+
if (containsListToken(obj)) {
206+
return TOKEN_MAP.resolveListTokens(obj);
207+
}
208+
186209
const arr = obj
187210
.map((x, i) => resolve(x, path.concat(i.toString())))
188211
.filter(x => typeof(x) !== 'undefined');
189212

190213
return arr;
191214
}
192215

216+
//
217+
// tokens - invoke 'resolve' and continue to resolve recursively
218+
//
219+
220+
if (unresolved(obj)) {
221+
const value = obj[RESOLVE_METHOD]();
222+
return resolve(value, path);
223+
}
224+
193225
//
194226
// objects - deep-resolve all values
195227
//
@@ -221,6 +253,14 @@ export function resolve(obj: any, prefix?: string[]): any {
221253
return result;
222254
}
223255

256+
function isListToken(x: any) {
257+
return typeof(x) === 'string' && TOKEN_MAP.createListTokenString(x).test();
258+
}
259+
260+
function containsListToken(xs: any[]) {
261+
return xs.some(isListToken);
262+
}
263+
224264
/**
225265
* Central place where we keep a mapping from Tokens to their String representation
226266
*
@@ -230,7 +270,7 @@ export function resolve(obj: any, prefix?: string[]): any {
230270
* All instances of TokenStringMap share the same storage, so that this process
231271
* works even when different copies of the library are loaded.
232272
*/
233-
class TokenStringMap {
273+
class TokenMap {
234274
private readonly tokenMap: {[key: string]: Token};
235275

236276
constructor() {
@@ -239,7 +279,7 @@ class TokenStringMap {
239279
}
240280

241281
/**
242-
* Generating a unique string for this Token, returning a key
282+
* Generate a unique string for this Token, returning a key
243283
*
244284
* Every call for the same Token will produce a new unique string, no
245285
* attempt is made to deduplicate. Token objects should cache the
@@ -249,35 +289,56 @@ class TokenStringMap {
249289
* hint. This may be used to produce aesthetically pleasing and
250290
* recognizable token representations for humans.
251291
*/
252-
public register(token: Token, representationHint?: string): string {
253-
const counter = Object.keys(this.tokenMap).length;
254-
const representation = representationHint || `TOKEN`;
292+
public registerString(token: Token, representationHint?: string): string {
293+
const key = this.register(token, representationHint);
294+
return `${BEGIN_STRING_TOKEN_MARKER}${key}${END_TOKEN_MARKER}`;
295+
}
255296

256-
const key = `${representation}.${counter}`;
257-
if (new RegExp(`[^${VALID_KEY_CHARS}]`).exec(key)) {
258-
throw new Error(`Invalid characters in token representation: ${key}`);
259-
}
297+
/**
298+
* Generate a unique string for this Token, returning a key
299+
*/
300+
public registerList(token: Token, representationHint?: string): string[] {
301+
const key = this.register(token, representationHint);
302+
return [`${BEGIN_LIST_TOKEN_MARKER}${key}${END_TOKEN_MARKER}`];
303+
}
260304

261-
this.tokenMap[key] = token;
262-
return `${BEGIN_TOKEN_MARKER}${key}${END_TOKEN_MARKER}`;
305+
/**
306+
* Returns a `TokenString` for this string.
307+
*/
308+
public createStringTokenString(s: string) {
309+
return new TokenString(s, BEGIN_STRING_TOKEN_MARKER, `[${VALID_KEY_CHARS}]+`, END_TOKEN_MARKER);
263310
}
264311

265312
/**
266313
* Returns a `TokenString` for this string.
267314
*/
268-
public createTokenString(s: string) {
269-
return new TokenString(s, BEGIN_TOKEN_MARKER, `[${VALID_KEY_CHARS}]+`, END_TOKEN_MARKER);
315+
public createListTokenString(s: string) {
316+
return new TokenString(s, BEGIN_LIST_TOKEN_MARKER, `[${VALID_KEY_CHARS}]+`, END_TOKEN_MARKER);
270317
}
271318

272319
/**
273320
* Replace any Token markers in this string with their resolved values
274321
*/
275-
public resolveMarkers(s: string): any {
276-
const str = this.createTokenString(s);
322+
public resolveStringTokens(s: string): any {
323+
const str = this.createStringTokenString(s);
277324
const fragments = str.split(this.lookupToken.bind(this));
278325
return fragments.join();
279326
}
280327

328+
public resolveListTokens(xs: string[]): any {
329+
// Must be a singleton list token, because concatenation is not allowed.
330+
if (xs.length !== 1) {
331+
throw new Error(`Cannot add elements to list token, got: ${xs}`);
332+
}
333+
334+
const str = this.createListTokenString(xs[0]);
335+
const fragments = str.split(this.lookupToken.bind(this));
336+
if (fragments.length !== 1) {
337+
throw new Error(`Cannot concatenate strings in a tokenized string array, got: ${xs[0]}`);
338+
}
339+
return fragments.values()[0];
340+
}
341+
281342
/**
282343
* Find a Token by key
283344
*/
@@ -288,16 +349,30 @@ class TokenStringMap {
288349

289350
return this.tokenMap[key];
290351
}
352+
353+
private register(token: Token, representationHint?: string): string {
354+
const counter = Object.keys(this.tokenMap).length;
355+
const representation = representationHint || `TOKEN`;
356+
357+
const key = `${representation}.${counter}`;
358+
if (new RegExp(`[^${VALID_KEY_CHARS}]`).exec(key)) {
359+
throw new Error(`Invalid characters in token representation: ${key}`);
360+
}
361+
362+
this.tokenMap[key] = token;
363+
return key;
364+
}
291365
}
292366

293-
const BEGIN_TOKEN_MARKER = '${Token[';
367+
const BEGIN_STRING_TOKEN_MARKER = '${Token[';
368+
const BEGIN_LIST_TOKEN_MARKER = '#{Token[';
294369
const END_TOKEN_MARKER = ']}';
295370
const VALID_KEY_CHARS = 'a-zA-Z0-9:._-';
296371

297372
/**
298373
* Singleton instance of the token string map
299374
*/
300-
const TOKEN_STRING_MAP = new TokenStringMap();
375+
const TOKEN_MAP = new TokenMap();
301376

302377
/**
303378
* Interface that Token joiners implement
@@ -382,6 +457,10 @@ type Fragment = StringFragment | TokenFragment;
382457
class TokenStringFragments {
383458
private readonly fragments = new Array<Fragment>();
384459

460+
public get length() {
461+
return this.fragments.length;
462+
}
463+
385464
public values(): any[] {
386465
return this.fragments.map(f => f.type === 'token' ? resolve(f.token) : f.str);
387466
}

0 commit comments

Comments
 (0)