Skip to content

Commit cc74f92

Browse files
author
Niranjan Jayakar
authored
feat(assertions): capture matching value (#16426)
The `assertions` module now has the ability to capture values during template matching. These captured values can then later be retrieved and used for further processing. This change also adds support for `anyValue()` matcher. This matcher will match any non-nullish targets during template matching. Migrated some tests in `pipelines` module to the `assertions` module, using the new capture and `anyValue()` features. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 430f50a commit cc74f92

17 files changed

+512
-173
lines changed

packages/@aws-cdk/assertions/README.md

+70-1
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ assert.hasResourceProperties('Foo::Bar', {
184184
The `Match.objectEquals()` API can be used to assert a target as a deep exact
185185
match.
186186

187-
In addition, the `Match.absentProperty()` can be used to specify that a specific
187+
### Presence and Absence
188+
189+
The `Match.absentProperty()` matcher can be used to specify that a specific
188190
property should not exist on the target. This can be used within `Match.objectLike()`
189191
or outside of any matchers.
190192

@@ -218,6 +220,42 @@ assert.hasResourceProperties('Foo::Bar', {
218220
});
219221
```
220222

223+
The `Match.anyValue()` matcher can be used to specify that a specific value should be found
224+
at the location. This matcher will fail if when the target location has null-ish values
225+
(i.e., `null` or `undefined`).
226+
227+
This matcher can be combined with any of the other matchers.
228+
229+
```ts
230+
// Given a template -
231+
// {
232+
// "Resources": {
233+
// "MyBar": {
234+
// "Type": "Foo::Bar",
235+
// "Properties": {
236+
// "Fred": {
237+
// "Wobble": ["Flob", "Flib"],
238+
// }
239+
// }
240+
// }
241+
// }
242+
// }
243+
244+
// The following will NOT throw an assertion error
245+
assert.hasResourceProperties('Foo::Bar', {
246+
Fred: {
247+
Wobble: [Match.anyValue(), "Flip"],
248+
},
249+
});
250+
251+
// The following will throw an assertion error
252+
assert.hasResourceProperties('Foo::Bar', {
253+
Fred: {
254+
Wimble: Match.anyValue(),
255+
},
256+
});
257+
```
258+
221259
### Array Matchers
222260

223261
The `Match.arrayWith()` API can be used to assert that the target is equal to or a subset
@@ -283,6 +321,37 @@ assert.hasResourceProperties('Foo::Bar', Match.objectLike({
283321
}});
284322
```
285323
324+
## Capturing Values
325+
326+
This matcher APIs documented above allow capturing values in the matching entry
327+
(Resource, Output, Mapping, etc.). The following code captures a string from a
328+
matching resource.
329+
330+
```ts
331+
// Given a template -
332+
// {
333+
// "Resources": {
334+
// "MyBar": {
335+
// "Type": "Foo::Bar",
336+
// "Properties": {
337+
// "Fred": ["Flob", "Cat"],
338+
// "Waldo": ["Qix", "Qux"],
339+
// }
340+
// }
341+
// }
342+
// }
343+
344+
const fredCapture = new Capture();
345+
const waldoCapture = new Capture();
346+
assert.hasResourceProperties('Foo::Bar', {
347+
Fred: fredCapture,
348+
Waldo: ["Qix", waldoCapture],
349+
});
350+
351+
fredCapture.asArray(); // returns ["Flob", "Cat"]
352+
waldoCapture.asString(); // returns "Qux"
353+
```
354+
286355
## Strongly typed languages
287356
288357
Some of the APIs documented above, such as `templateMatches()` and
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Matcher, MatchResult } from './matcher';
2+
import { Type, getType } from './private/type';
3+
4+
/**
5+
* Capture values while matching templates.
6+
* Using an instance of this class within a Matcher will capture the matching value.
7+
* The `as*()` APIs on the instance can be used to get the captured value.
8+
*/
9+
export class Capture extends Matcher {
10+
public readonly name: string;
11+
private value: any = null;
12+
13+
constructor() {
14+
super();
15+
this.name = 'Capture';
16+
}
17+
18+
public test(actual: any): MatchResult {
19+
this.value = actual;
20+
21+
const result = new MatchResult(actual);
22+
if (actual == null) {
23+
result.push(this, [], `Can only capture non-nullish values. Found ${actual}`);
24+
}
25+
return result;
26+
}
27+
28+
/**
29+
* Retrieve the captured value as a string.
30+
* An error is generated if no value is captured or if the value is not a string.
31+
*/
32+
public asString(): string {
33+
this.checkNotNull();
34+
if (getType(this.value) === 'string') {
35+
return this.value;
36+
}
37+
this.reportIncorrectType('string');
38+
}
39+
40+
/**
41+
* Retrieve the captured value as a number.
42+
* An error is generated if no value is captured or if the value is not a number.
43+
*/
44+
public asNumber(): number {
45+
this.checkNotNull();
46+
if (getType(this.value) === 'number') {
47+
return this.value;
48+
}
49+
this.reportIncorrectType('number');
50+
}
51+
52+
/**
53+
* Retrieve the captured value as a boolean.
54+
* An error is generated if no value is captured or if the value is not a boolean.
55+
*/
56+
public asBoolean(): boolean {
57+
this.checkNotNull();
58+
if (getType(this.value) === 'boolean') {
59+
return this.value;
60+
}
61+
this.reportIncorrectType('boolean');
62+
}
63+
64+
/**
65+
* Retrieve the captured value as an array.
66+
* An error is generated if no value is captured or if the value is not an array.
67+
*/
68+
public asArray(): any[] {
69+
this.checkNotNull();
70+
if (getType(this.value) === 'array') {
71+
return this.value;
72+
}
73+
this.reportIncorrectType('array');
74+
}
75+
76+
/**
77+
* Retrieve the captured value as a JSON object.
78+
* An error is generated if no value is captured or if the value is not an object.
79+
*/
80+
public asObject(): { [key: string]: any } {
81+
this.checkNotNull();
82+
if (getType(this.value) === 'object') {
83+
return this.value;
84+
}
85+
this.reportIncorrectType('object');
86+
}
87+
88+
private checkNotNull(): void {
89+
if (this.value == null) {
90+
throw new Error('No value captured');
91+
}
92+
}
93+
94+
private reportIncorrectType(expected: Type): never {
95+
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this.value)}. ` +
96+
`Value is ${JSON.stringify(this.value, undefined, 2)}`);
97+
}
98+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './capture';
12
export * from './template';
23
export * from './match';
34
export * from './matcher';

packages/@aws-cdk/assertions/lib/match.ts

+30-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Matcher, MatchResult } from './matcher';
2+
import { getType } from './private/type';
23
import { ABSENT } from './vendored/assert';
34

45
/**
@@ -63,6 +64,13 @@ export abstract class Match {
6364
public static not(pattern: any): Matcher {
6465
return new NotMatch('not', pattern);
6566
}
67+
68+
/**
69+
* Matches any non-null value at the target.
70+
*/
71+
public static anyValue(): Matcher {
72+
return new AnyMatch('anyValue');
73+
}
6674
}
6775

6876
/**
@@ -141,22 +149,22 @@ interface ArrayMatchOptions {
141149
* Match class that matches arrays.
142150
*/
143151
class ArrayMatch extends Matcher {
144-
private readonly partial: boolean;
152+
private readonly subsequence: boolean;
145153

146154
constructor(
147155
public readonly name: string,
148156
private readonly pattern: any[],
149157
options: ArrayMatchOptions = {}) {
150158

151159
super();
152-
this.partial = options.subsequence ?? true;
160+
this.subsequence = options.subsequence ?? true;
153161
}
154162

155163
public test(actual: any): MatchResult {
156164
if (!Array.isArray(actual)) {
157165
return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`);
158166
}
159-
if (!this.partial && this.pattern.length !== actual.length) {
167+
if (!this.subsequence && this.pattern.length !== actual.length) {
160168
return new MatchResult(actual).push(this, [], `Expected array of length ${this.pattern.length} but received ${actual.length}`);
161169
}
162170

@@ -166,10 +174,16 @@ class ArrayMatch extends Matcher {
166174
const result = new MatchResult(actual);
167175
while (patternIdx < this.pattern.length && actualIdx < actual.length) {
168176
const patternElement = this.pattern[patternIdx];
177+
169178
const matcher = Matcher.isMatcher(patternElement) ? patternElement : new LiteralMatch(this.name, patternElement);
179+
if (this.subsequence && matcher instanceof AnyMatch) {
180+
// array subsequence matcher is not compatible with anyValue() matcher. They don't make sense to be used together.
181+
throw new Error('The Matcher anyValue() cannot be nested within arrayWith()');
182+
}
183+
170184
const innerResult = matcher.test(actual[actualIdx]);
171185

172-
if (!this.partial || !innerResult.hasFailed()) {
186+
if (!this.subsequence || !innerResult.hasFailed()) {
173187
result.compose(`[${actualIdx}]`, innerResult);
174188
patternIdx++;
175189
actualIdx++;
@@ -271,6 +285,16 @@ class NotMatch extends Matcher {
271285
}
272286
}
273287

274-
function getType(obj: any): string {
275-
return Array.isArray(obj) ? 'array' : typeof obj;
288+
class AnyMatch extends Matcher {
289+
constructor(public readonly name: string) {
290+
super();
291+
}
292+
293+
public test(actual: any): MatchResult {
294+
const result = new MatchResult(actual);
295+
if (actual == null) {
296+
result.push(this, [], 'Expected a value but found none');
297+
}
298+
return result;
299+
}
276300
}

packages/@aws-cdk/assertions/lib/matcher.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export abstract class Matcher {
1717

1818
/**
1919
* Test whether a target matches the provided pattern.
20+
* Every Matcher must implement this method.
21+
* This method will be invoked by the assertions framework. Do not call this method directly.
2022
* @param actual the target to match
2123
* @return the list of match failures. An empty array denotes a successful match.
2224
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type Type = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array';
2+
3+
export function getType(obj: any): Type {
4+
return Array.isArray(obj) ? 'array' : typeof obj;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Capture, Match } from '../lib';
2+
3+
describe('Capture', () => {
4+
test('uncaptured', () => {
5+
const capture = new Capture();
6+
expect(() => capture.asString()).toThrow(/No value captured/);
7+
});
8+
9+
test('nullish', () => {
10+
const capture = new Capture();
11+
const matcher = Match.objectEquals({ foo: capture });
12+
13+
const result = matcher.test({ foo: null });
14+
expect(result.failCount).toEqual(1);
15+
expect(result.toHumanStrings()[0]).toMatch(/Can only capture non-nullish values/);
16+
});
17+
18+
test('asString()', () => {
19+
const capture = new Capture();
20+
const matcher = Match.objectEquals({ foo: capture });
21+
22+
matcher.test({ foo: 'bar' });
23+
expect(capture.asString()).toEqual('bar');
24+
25+
matcher.test({ foo: 3 });
26+
expect(() => capture.asString()).toThrow(/expected to be string but found number/);
27+
});
28+
29+
test('asNumber()', () => {
30+
const capture = new Capture();
31+
const matcher = Match.objectEquals({ foo: capture });
32+
33+
matcher.test({ foo: 3 });
34+
expect(capture.asNumber()).toEqual(3);
35+
36+
matcher.test({ foo: 'bar' });
37+
expect(() => capture.asNumber()).toThrow(/expected to be number but found string/);
38+
});
39+
40+
test('asArray()', () => {
41+
const capture = new Capture();
42+
const matcher = Match.objectEquals({ foo: capture });
43+
44+
matcher.test({ foo: ['bar'] });
45+
expect(capture.asArray()).toEqual(['bar']);
46+
47+
matcher.test({ foo: 'bar' });
48+
expect(() => capture.asArray()).toThrow(/expected to be array but found string/);
49+
});
50+
51+
test('asObject()', () => {
52+
const capture = new Capture();
53+
const matcher = Match.objectEquals({ foo: capture });
54+
55+
matcher.test({ foo: { fred: 'waldo' } });
56+
expect(capture.asObject()).toEqual({ fred: 'waldo' });
57+
58+
matcher.test({ foo: 'bar' });
59+
expect(() => capture.asObject()).toThrow(/expected to be object but found string/);
60+
});
61+
62+
test('nested within an array', () => {
63+
const capture = new Capture();
64+
const matcher = Match.objectEquals({ foo: ['bar', capture] });
65+
66+
matcher.test({ foo: ['bar', 'baz'] });
67+
expect(capture.asString()).toEqual('baz');
68+
});
69+
});

0 commit comments

Comments
 (0)