Skip to content

Commit

Permalink
feat(assertions): queries and assertions against the Outputs and Mapp…
Browse files Browse the repository at this point in the history
…ings sections (aws#15892)

Introduce APIs `hasOutput()`, `findOutputs()`, `hasMapping()`
and `findMappings()` to assert the `Mappings` and `Outputs`
section of the CloudFormation template.

Also, refactored the implementation of `hasResource()` to
increase re-usability of its implementation across these new APIs.

Migrated the modules `aws-kinesisfirehose` and `aws-neptune`
that use these new APIs.

----

*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 authored and hollanddd committed Aug 26, 2021
1 parent 0fde072 commit 5bd672b
Show file tree
Hide file tree
Showing 18 changed files with 566 additions and 233 deletions.
18 changes: 15 additions & 3 deletions packages/@aws-cdk/assertions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,23 @@ By default, the `hasResource()` and `hasResourceProperties()` APIs perform deep
partial object matching. This behavior can be configured using matchers.
See subsequent section on [special matchers](#special-matchers).

## Other Sections

Similar to the `hasResource()` and `findResources()`, we have equivalent methods
to check and find other sections of the CloudFormation resources.

* Outputs - `hasOutput()` and `findOutputs()`
* Mapping - `hasMapping()` and `findMappings()`

All of the defaults and behaviour documented for `hasResource()` and
`findResources()` apply to these methods.

## Special Matchers

The expectation provided to the `hasResourceXXX()` methods, besides carrying
literal values, as seen in the above examples, can also have special matchers
encoded.
The expectation provided to the `hasXXX()` and `findXXX()` methods, besides
carrying literal values, as seen in the above examples, also accept special
matchers.

They are available as part of the `Match` class.

### Object Matchers
Expand Down
12 changes: 6 additions & 6 deletions packages/@aws-cdk/assertions/lib/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class LiteralMatch extends Matcher {
return new ObjectMatch(this.name, this.pattern, { partial: this.partialObjects }).test(actual);
}

const result = new MatchResult();
const result = new MatchResult(actual);
if (typeof this.pattern !== typeof actual) {
result.push(this, [], `Expected type ${typeof this.pattern} but received ${getType(actual)}`);
return result;
Expand Down Expand Up @@ -152,16 +152,16 @@ class ArrayMatch extends Matcher {

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

let patternIdx = 0;
let actualIdx = 0;

const result = new MatchResult();
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);
Expand Down Expand Up @@ -220,10 +220,10 @@ class ObjectMatch extends Matcher {

public test(actual: any): MatchResult {
if (typeof actual !== 'object' || Array.isArray(actual)) {
return new MatchResult().push(this, [], `Expected type object but received ${getType(actual)}`);
return new MatchResult(actual).push(this, [], `Expected type object but received ${getType(actual)}`);
}

const result = new MatchResult();
const result = new MatchResult(actual);
if (!this.partial) {
for (const a of Object.keys(actual)) {
if (!(a in this.pattern)) {
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/assertions/lib/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,16 @@ export abstract class Matcher {
* The result of `Match.test()`.
*/
export class MatchResult {
/**
* The target for which this result was generated.
*/
public readonly target: any;
private readonly failures: MatchFailure[] = [];

constructor(target: any) {
this.target = target;
}

/**
* Push a new failure into this result at a specific path.
* If the failure occurred at root of the match tree, set the path to an empty list.
Expand Down
31 changes: 31 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/mappings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { StackInspector } from '../vendored/assert';
import { formatFailure, matchSection } from './section';

export function findMappings(inspector: StackInspector, props: any = {}): { [key: string]: any }[] {
const section: { [key: string] : {} } = inspector.value.Mappings;
const result = matchSection(section, props);

if (!result.match) {
return [];
}

return result.matches;
}

export function hasMapping(inspector: StackInspector, props: any): string | void {
const section: { [key: string]: {} } = inspector.value.Mappings;
const result = matchSection(section, props);

if (result.match) {
return;
}

if (result.closestResult === undefined) {
return 'No mappings found in the template';
}

return [
`Template has ${result.analyzedCount} mappings, but none match as expected.`,
formatFailure(result.closestResult),
].join('\n');
}
31 changes: 31 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/outputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { StackInspector } from '../vendored/assert';
import { formatFailure, matchSection } from './section';

export function findOutputs(inspector: StackInspector, props: any = {}): { [key: string]: any }[] {
const section: { [key: string] : {} } = inspector.value.Outputs;
const result = matchSection(section, props);

if (!result.match) {
return [];
}

return result.matches;
}

export function hasOutput(inspector: StackInspector, props: any): string | void {
const section: { [key: string]: {} } = inspector.value.Outputs;
const result = matchSection(section, props);

if (result.match) {
return;
}

if (result.closestResult === undefined) {
return 'No outputs found in the template';
}

return [
`Template has ${result.analyzedCount} outputs, but none match as expected.`,
formatFailure(result.closestResult),
].join('\n');
}
82 changes: 0 additions & 82 deletions packages/@aws-cdk/assertions/lib/private/resource.ts

This file was deleted.

42 changes: 42 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { StackInspector } from '../vendored/assert';
import { formatFailure, matchSection } from './section';

// Partial type for CloudFormation Resource
type Resource = {
Type: string;
}

export function findResources(inspector: StackInspector, type: string, props: any = {}): { [key: string]: any }[] {
const section: { [key: string] : Resource } = inspector.value.Resources;
const result = matchSection(filterType(section, type), props);

if (!result.match) {
return [];
}

return result.matches;
}

export function hasResource(inspector: StackInspector, type: string, props: any): string | void {
const section: { [key: string]: Resource } = inspector.value.Resources;
const result = matchSection(filterType(section, type), props);

if (result.match) {
return;
}

if (result.closestResult === undefined) {
return `No resource with type ${type} found`;
}

return [
`Template has ${result.analyzedCount} resources with type ${type}, but none match as expected.`,
formatFailure(result.closestResult),
].join('\n');
}

function filterType(section: { [key: string]: Resource }, type: string): { [key: string]: Resource } {
return Object.entries(section ?? {})
.filter(([_, v]) => v.Type === type)
.reduce((agg, [k, v]) => { return { ...agg, [k]: v }; }, {});
}
58 changes: 58 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/section.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Match } from '../match';
import { Matcher, MatchResult } from '../matcher';

export type MatchSuccess = { match: true, matches: any[] };
export type MatchFailure = { match: false, closestResult?: MatchResult, analyzedCount: number };

export function matchSection(section: any, props: any): MatchSuccess | MatchFailure {
const matcher = Matcher.isMatcher(props) ? props : Match.objectLike(props);
let closestResult: MatchResult | undefined = undefined;
let matching: any[] = [];
let count = 0;

eachEntryInSection(
section,

(entry) => {
const result = matcher.test(entry);
if (!result.hasFailed()) {
matching.push(entry);
} else {
count++;
if (closestResult === undefined || closestResult.failCount > result.failCount) {
closestResult = result;
}
}
},
);

if (matching.length > 0) {
return { match: true, matches: matching };
} else {
return { match: false, closestResult, analyzedCount: count };
}
}

function eachEntryInSection(
section: any,
cb: (entry: {[key: string]: any}) => void): void {

for (const logicalId of Object.keys(section ?? {})) {
const resource: { [key: string]: any } = section[logicalId];
cb(resource);
}
}

export function formatFailure(closestResult: MatchResult): string {
return [
'The closest result is:',
leftPad(JSON.stringify(closestResult.target, undefined, 2)),
'with the following mismatches:',
...closestResult.toHumanStrings().map(s => `\t${s}`),
].join('\n');
}

function leftPad(x: string, indent: number = 2): string {
const pad = ' '.repeat(indent);
return pad + x.split('\n').join(`\n${pad}`);
}
Loading

0 comments on commit 5bd672b

Please sign in to comment.