Skip to content

Commit

Permalink
feat(assertions): capture matching value (#16426)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
Niranjan Jayakar committed Sep 9, 2021
1 parent 430f50a commit cc74f92
Show file tree
Hide file tree
Showing 17 changed files with 512 additions and 173 deletions.
71 changes: 70 additions & 1 deletion packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ assert.hasResourceProperties('Foo::Bar', {
The `Match.objectEquals()` API can be used to assert a target as a deep exact
match.

In addition, the `Match.absentProperty()` can be used to specify that a specific
### Presence and Absence

The `Match.absentProperty()` matcher can be used to specify that a specific
property should not exist on the target. This can be used within `Match.objectLike()`
or outside of any matchers.

Expand Down Expand Up @@ -218,6 +220,42 @@ assert.hasResourceProperties('Foo::Bar', {
});
```

The `Match.anyValue()` matcher can be used to specify that a specific value should be found
at the location. This matcher will fail if when the target location has null-ish values
(i.e., `null` or `undefined`).

This matcher can be combined with any of the other matchers.

```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": {
// "Wobble": ["Flob", "Flib"],
// }
// }
// }
// }
// }

// The following will NOT throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Fred: {
Wobble: [Match.anyValue(), "Flip"],
},
});

// The following will throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Fred: {
Wimble: Match.anyValue(),
},
});
```

### Array Matchers

The `Match.arrayWith()` API can be used to assert that the target is equal to or a subset
Expand Down Expand Up @@ -283,6 +321,37 @@ assert.hasResourceProperties('Foo::Bar', Match.objectLike({
}});
```
## Capturing Values
This matcher APIs documented above allow capturing values in the matching entry
(Resource, Output, Mapping, etc.). The following code captures a string from a
matching resource.
```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Flob", "Cat"],
// "Waldo": ["Qix", "Qux"],
// }
// }
// }
// }

const fredCapture = new Capture();
const waldoCapture = new Capture();
assert.hasResourceProperties('Foo::Bar', {
Fred: fredCapture,
Waldo: ["Qix", waldoCapture],
});

fredCapture.asArray(); // returns ["Flob", "Cat"]
waldoCapture.asString(); // returns "Qux"
```
## Strongly typed languages
Some of the APIs documented above, such as `templateMatches()` and
Expand Down
98 changes: 98 additions & 0 deletions packages/@aws-cdk/assertions/lib/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Matcher, MatchResult } from './matcher';
import { Type, getType } from './private/type';

/**
* Capture values while matching templates.
* Using an instance of this class within a Matcher will capture the matching value.
* The `as*()` APIs on the instance can be used to get the captured value.
*/
export class Capture extends Matcher {
public readonly name: string;
private value: any = null;

constructor() {
super();
this.name = 'Capture';
}

public test(actual: any): MatchResult {
this.value = actual;

const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], `Can only capture non-nullish values. Found ${actual}`);
}
return result;
}

/**
* Retrieve the captured value as a string.
* An error is generated if no value is captured or if the value is not a string.
*/
public asString(): string {
this.checkNotNull();
if (getType(this.value) === 'string') {
return this.value;
}
this.reportIncorrectType('string');
}

/**
* Retrieve the captured value as a number.
* An error is generated if no value is captured or if the value is not a number.
*/
public asNumber(): number {
this.checkNotNull();
if (getType(this.value) === 'number') {
return this.value;
}
this.reportIncorrectType('number');
}

/**
* Retrieve the captured value as a boolean.
* An error is generated if no value is captured or if the value is not a boolean.
*/
public asBoolean(): boolean {
this.checkNotNull();
if (getType(this.value) === 'boolean') {
return this.value;
}
this.reportIncorrectType('boolean');
}

/**
* Retrieve the captured value as an array.
* An error is generated if no value is captured or if the value is not an array.
*/
public asArray(): any[] {
this.checkNotNull();
if (getType(this.value) === 'array') {
return this.value;
}
this.reportIncorrectType('array');
}

/**
* Retrieve the captured value as a JSON object.
* An error is generated if no value is captured or if the value is not an object.
*/
public asObject(): { [key: string]: any } {
this.checkNotNull();
if (getType(this.value) === 'object') {
return this.value;
}
this.reportIncorrectType('object');
}

private checkNotNull(): void {
if (this.value == null) {
throw new Error('No value captured');
}
}

private reportIncorrectType(expected: Type): never {
throw new Error(`Captured value is expected to be ${expected} but found ${getType(this.value)}. ` +
`Value is ${JSON.stringify(this.value, undefined, 2)}`);
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/assertions/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './capture';
export * from './template';
export * from './match';
export * from './matcher';
36 changes: 30 additions & 6 deletions packages/@aws-cdk/assertions/lib/match.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Matcher, MatchResult } from './matcher';
import { getType } from './private/type';
import { ABSENT } from './vendored/assert';

/**
Expand Down Expand Up @@ -63,6 +64,13 @@ export abstract class Match {
public static not(pattern: any): Matcher {
return new NotMatch('not', pattern);
}

/**
* Matches any non-null value at the target.
*/
public static anyValue(): Matcher {
return new AnyMatch('anyValue');
}
}

/**
Expand Down Expand Up @@ -141,22 +149,22 @@ interface ArrayMatchOptions {
* Match class that matches arrays.
*/
class ArrayMatch extends Matcher {
private readonly partial: boolean;
private readonly subsequence: boolean;

constructor(
public readonly name: string,
private readonly pattern: any[],
options: ArrayMatchOptions = {}) {

super();
this.partial = options.subsequence ?? true;
this.subsequence = options.subsequence ?? true;
}

public test(actual: any): MatchResult {
if (!Array.isArray(actual)) {
return new MatchResult(actual).push(this, [], `Expected type array but received ${getType(actual)}`);
}
if (!this.partial && this.pattern.length !== actual.length) {
if (!this.subsequence && this.pattern.length !== actual.length) {
return new MatchResult(actual).push(this, [], `Expected array of length ${this.pattern.length} but received ${actual.length}`);
}

Expand All @@ -166,10 +174,16 @@ class ArrayMatch extends Matcher {
const result = new MatchResult(actual);
while (patternIdx < this.pattern.length && actualIdx < actual.length) {
const patternElement = this.pattern[patternIdx];

const matcher = Matcher.isMatcher(patternElement) ? patternElement : new LiteralMatch(this.name, patternElement);
if (this.subsequence && matcher instanceof AnyMatch) {
// array subsequence matcher is not compatible with anyValue() matcher. They don't make sense to be used together.
throw new Error('The Matcher anyValue() cannot be nested within arrayWith()');
}

const innerResult = matcher.test(actual[actualIdx]);

if (!this.partial || !innerResult.hasFailed()) {
if (!this.subsequence || !innerResult.hasFailed()) {
result.compose(`[${actualIdx}]`, innerResult);
patternIdx++;
actualIdx++;
Expand Down Expand Up @@ -271,6 +285,16 @@ class NotMatch extends Matcher {
}
}

function getType(obj: any): string {
return Array.isArray(obj) ? 'array' : typeof obj;
class AnyMatch extends Matcher {
constructor(public readonly name: string) {
super();
}

public test(actual: any): MatchResult {
const result = new MatchResult(actual);
if (actual == null) {
result.push(this, [], 'Expected a value but found none');
}
return result;
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/assertions/lib/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export abstract class Matcher {

/**
* Test whether a target matches the provided pattern.
* Every Matcher must implement this method.
* This method will be invoked by the assertions framework. Do not call this method directly.
* @param actual the target to match
* @return the list of match failures. An empty array denotes a successful match.
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type Type = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array';

export function getType(obj: any): Type {
return Array.isArray(obj) ? 'array' : typeof obj;
}
69 changes: 69 additions & 0 deletions packages/@aws-cdk/assertions/test/capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Capture, Match } from '../lib';

describe('Capture', () => {
test('uncaptured', () => {
const capture = new Capture();
expect(() => capture.asString()).toThrow(/No value captured/);
});

test('nullish', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

const result = matcher.test({ foo: null });
expect(result.failCount).toEqual(1);
expect(result.toHumanStrings()[0]).toMatch(/Can only capture non-nullish values/);
});

test('asString()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: 'bar' });
expect(capture.asString()).toEqual('bar');

matcher.test({ foo: 3 });
expect(() => capture.asString()).toThrow(/expected to be string but found number/);
});

test('asNumber()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: 3 });
expect(capture.asNumber()).toEqual(3);

matcher.test({ foo: 'bar' });
expect(() => capture.asNumber()).toThrow(/expected to be number but found string/);
});

test('asArray()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: ['bar'] });
expect(capture.asArray()).toEqual(['bar']);

matcher.test({ foo: 'bar' });
expect(() => capture.asArray()).toThrow(/expected to be array but found string/);
});

test('asObject()', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: capture });

matcher.test({ foo: { fred: 'waldo' } });
expect(capture.asObject()).toEqual({ fred: 'waldo' });

matcher.test({ foo: 'bar' });
expect(() => capture.asObject()).toThrow(/expected to be object but found string/);
});

test('nested within an array', () => {
const capture = new Capture();
const matcher = Match.objectEquals({ foo: ['bar', capture] });

matcher.test({ foo: ['bar', 'baz'] });
expect(capture.asString()).toEqual('baz');
});
});
Loading

0 comments on commit cc74f92

Please sign in to comment.