Skip to content

Commit

Permalink
feat(assertions): match into serialized json (#16456)
Browse files Browse the repository at this point in the history
Introduce `Match.serializedJson()` that can parse JSON serialized as a
string, and continue matching into the parsed JSON.

Migrate the rest of the tests in the `pipelines` module.

----

*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 13, 2021
1 parent 5835c2d commit fed30fc
Show file tree
Hide file tree
Showing 14 changed files with 675 additions and 532 deletions.
41 changes: 41 additions & 0 deletions packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,47 @@ assert.hasResourceProperties('Foo::Bar', Match.objectLike({
}});
```
### Serialized JSON
Often, we find that some CloudFormation Resource types declare properties as a string,
but actually expect JSON serialized as a string.
For example, the [`BuildSpec` property of `AWS::CodeBuild::Project`][Pipeline BuildSpec],
the [`Definition` property of `AWS::StepFunctions::StateMachine`][StateMachine Definition],
to name a couple.
The `Match.serializedJson()` matcher allows deep matching within a stringified JSON.
```ts
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Baz": "{ \"Fred\": [\"Waldo\", \"Willow\"] }"
// }
// }
// }
// }

// The following will NOT throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Baz: Match.serializedJson({
Fred: Match.arrayWith(["Waldo"]),
}),
});

// The following will throw an assertion error
assert.hasResourceProperties('Foo::Bar', {
Baz: Match.serializedJson({
Fred: ["Waldo", "Johnny"],
}),
});
```
[Pipeline BuildSpec]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-codebuild-project-source.html#cfn-codebuild-project-source-buildspec
[StateMachine Definition]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-stepfunctions-statemachine.html#cfn-stepfunctions-statemachine-definition
## Capturing Values
This matcher APIs documented above allow capturing values in the matching entry
Expand Down
41 changes: 41 additions & 0 deletions packages/@aws-cdk/assertions/lib/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export abstract class Match {
return new NotMatch('not', pattern);
}

/**
* Matches any string-encoded JSON and applies the specified pattern after parsing it.
* @param pattern the pattern to match after parsing the encoded JSON.
*/
public static serializedJson(pattern: any): Matcher {
return new SerializedJson('serializedJson', pattern);
}

/**
* Matches any non-null value at the target.
*/
Expand Down Expand Up @@ -265,6 +273,39 @@ class ObjectMatch extends Matcher {
}
}

class SerializedJson extends Matcher {
constructor(
public readonly name: string,
private readonly pattern: any,
) {
super();
};

public test(actual: any): MatchResult {
const result = new MatchResult(actual);
if (getType(actual) !== 'string') {
result.push(this, [], `Expected JSON as a string but found ${getType(actual)}`);
return result;
}
let parsed;
try {
parsed = JSON.parse(actual);
} catch (err) {
if (err instanceof SyntaxError) {
result.push(this, [], `Invalid JSON string: ${actual}`);
return result;
} else {
throw err;
}
}

const matcher = Matcher.isMatcher(this.pattern) ? this.pattern : new LiteralMatch(this.name, this.pattern);
const innerResult = matcher.test(parsed);
result.compose(`(${this.name})`, innerResult);
return result;
}
}

class NotMatch extends Matcher {
constructor(
public readonly name: string,
Expand Down
51 changes: 48 additions & 3 deletions packages/@aws-cdk/assertions/test/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,18 +323,63 @@ describe('Matchers', () => {
expectFailure(matcher, {}, ['Missing key at /foo']);
});
});

describe('serializedJson()', () => {
let matcher: Matcher;

test('all types', () => {
matcher = Match.serializedJson({ Foo: 'Bar', Baz: 3, Boo: true, Fred: [1, 2] });
expectPass(matcher, '{ "Foo": "Bar", "Baz": 3, "Boo": true, "Fred": [1, 2] }');
});

test('simple match', () => {
matcher = Match.serializedJson({ Foo: 'Bar' });
expectPass(matcher, '{ "Foo": "Bar" }');

expectFailure(matcher, '{ "Foo": "Baz" }', ['Expected Bar but received Baz at (serializedJson)/Foo']);
expectFailure(matcher, '{ "Foo": 4 }', ['Expected type string but received number at (serializedJson)/Foo']);
expectFailure(matcher, '{ "Bar": "Baz" }', [
'Unexpected key at (serializedJson)/Bar',
'Missing key at (serializedJson)/Foo',
]);
});

test('nested matcher', () => {
matcher = Match.serializedJson(Match.objectLike({
Foo: Match.arrayWith(['Bar']),
}));

expectPass(matcher, '{ "Foo": ["Bar"] }');
expectPass(matcher, '{ "Foo": ["Bar", "Baz"] }');
expectPass(matcher, '{ "Foo": ["Bar", "Baz"], "Fred": "Waldo" }');

expectFailure(matcher, '{ "Foo": ["Baz"] }', ['Missing element [Bar] at pattern index 0 at (serializedJson)/Foo']);
expectFailure(matcher, '{ "Bar": ["Baz"] }', ['Missing key at (serializedJson)/Foo']);
});

test('invalid json string', () => {
matcher = Match.serializedJson({ Foo: 'Bar' });

expectFailure(matcher, '{ "Foo"', [/invalid JSON string/i]);
});
});
});

function expectPass(matcher: Matcher, target: any): void {
expect(matcher.test(target).hasFailed()).toEqual(false);
const result = matcher.test(target);
if (result.hasFailed()) {
fail(result.toHumanStrings()); // eslint-disable-line jest/no-jasmine-globals
}
}

function expectFailure(matcher: Matcher, target: any, expected: (string | RegExp)[] = []): void {
const result = matcher.test(target);
expect(result.failCount).toBeGreaterThan(0);
const actual = result.toHumanStrings();
if (expected.length > 0) {
expect(actual.length).toEqual(expected.length);
if (expected.length > 0 && actual.length !== expected.length) {
// only do this if the lengths are different, so as to display a nice failure message.
// otherwise need to use `toMatch()` to support RegExp
expect(actual).toEqual(expected);
}
for (let i = 0; i < expected.length; i++) {
const e = expected[i];
Expand Down
1 change: 0 additions & 1 deletion packages/@aws-cdk/pipelines/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"organization": true
},
"devDependencies": {
"@aws-cdk/assert-internal": "0.0.0",
"@aws-cdk/assertions": "0.0.0",
"@aws-cdk/aws-apigateway": "0.0.0",
"@aws-cdk/aws-ecr-assets": "0.0.0",
Expand Down
Loading

0 comments on commit fed30fc

Please sign in to comment.