Skip to content

Commit

Permalink
Merge pull request #853 from guardian/aa-mixin-app-aware
Browse files Browse the repository at this point in the history
refactor: Add `GuAppAwareConstruct` mixin
  • Loading branch information
akash1810 authored Oct 22, 2021
2 parents da7c707 + fb3be1c commit d9aeea8
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 4 deletions.
9 changes: 5 additions & 4 deletions src/constructs/acm/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HostedZone } from "@aws-cdk/aws-route53";
import { RemovalPolicy } from "@aws-cdk/core";
import { Stage } from "../../constants";
import { GuStatefulMigratableConstruct } from "../../utils/mixin";
import { GuAppAwareConstruct } from "../../utils/mixin/app-aware-construct";
import type { GuStack } from "../core";
import { AppIdentity } from "../core/identity";
import type { GuMigratingResource } from "../core/migrating";
Expand Down Expand Up @@ -58,7 +59,7 @@ export interface GuDnsValidatedCertificateProps {
* });
*```
*/
export class GuCertificate extends GuStatefulMigratableConstruct(Certificate) {
export class GuCertificate extends GuStatefulMigratableConstruct(GuAppAwareConstruct(Certificate)) {
constructor(scope: GuStack, props: GuCertificatePropsWithApp) {
const maybeHostedZone =
props.CODE.hostedZoneId && props.PROD.hostedZoneId
Expand All @@ -71,16 +72,16 @@ export class GuCertificate extends GuStatefulMigratableConstruct(Certificate) {
})
)
: undefined;
const awsCertificateProps: CertificateProps & GuMigratingResource = {
const awsCertificateProps: CertificateProps & GuMigratingResource & AppIdentity = {
domainName: scope.withStageDependentValue({
variableName: "domainName",
stageValues: { [Stage.CODE]: props.CODE.domainName, [Stage.PROD]: props.PROD.domainName },
}),
validation: CertificateValidation.fromDns(maybeHostedZone),
existingLogicalId: props.existingLogicalId,
app: props.app,
};
super(scope, AppIdentity.suffixText({ app: props.app }, "Certificate"), awsCertificateProps);
super(scope, "Certificate", awsCertificateProps);
this.applyRemovalPolicy(RemovalPolicy.RETAIN);
AppIdentity.taggedConstruct({ app: props.app }, this);
}
}
5 changes: 5 additions & 0 deletions src/constructs/core/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export interface AppIdentity {
export interface Identity extends StackStageIdentity, AppIdentity {}

export const AppIdentity = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -- user defined type guard
isAppIdentity(props: any): props is AppIdentity {
return props ? "app" in props : false;
},

suffixText(appIdentity: AppIdentity, text: string): string {
const titleCaseApp = appIdentity.app.charAt(0).toUpperCase() + appIdentity.app.slice(1);
// CloudFormation Logical Ids must be alphanumeric, so remove any non-alphanumeric characters: https://stackoverflow.com/a/20864946
Expand Down
50 changes: 50 additions & 0 deletions src/utils/mixin/__snapshots__/app-aware-contruct.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`The GuAppAwareConstruct mixin should add the app tag 1`] = `
Object {
"Parameters": Object {
"Stage": Object {
"AllowedValues": Array [
"CODE",
"PROD",
],
"Default": "CODE",
"Description": "Stage name",
"Type": "String",
},
},
"Resources": Object {
"MyBucketTest262E966E": Object {
"DeletionPolicy": "Retain",
"Properties": Object {
"Tags": Array [
Object {
"Key": "App",
"Value": "Test",
},
Object {
"Key": "gu:cdk:version",
"Value": "TEST",
},
Object {
"Key": "gu:repo",
"Value": "guardian/cdk",
},
Object {
"Key": "Stack",
"Value": "test-stack",
},
Object {
"Key": "Stage",
"Value": Object {
"Ref": "Stage",
},
},
],
},
"Type": "AWS::S3::Bucket",
"UpdateReplacePolicy": "Retain",
},
},
}
`;
44 changes: 44 additions & 0 deletions src/utils/mixin/app-aware-construct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Construct } from "@aws-cdk/core";
import { AppIdentity } from "../../constructs/core/identity";
import type { AnyConstructor } from "./types";

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -- mixin
export function GuAppAwareConstruct<TBase extends AnyConstructor>(BaseClass: TBase) {
class Mixin extends BaseClass {
/**
* The ID of the construct with the App suffix.
* This should be used in place of `id` when trying to reference the construct.
*/
readonly idWithApp: string;

// eslint-disable-next-line custom-rules/valid-constructors, @typescript-eslint/no-explicit-any -- mixin
protected constructor(...args: any[]) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- mixin
const [scope, id, props, ...rest] = args;

if (!AppIdentity.isAppIdentity(props)) {
throw new Error("Cannot use the GuAppAwareConstruct mixin without an AppIdentity");
}

const app: string = props.app;
const idWithApp = AppIdentity.suffixText({ app }, id as string);

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- mixin
const newArgs = [scope, idWithApp, props, ...rest];
super(...newArgs);

this.idWithApp = idWithApp;

/*
Add the `App` tag to the construct.
Although not every resource can be tagged, it's still safe to make the call.
If AWS support tags on a new resource one day, our test suite will fail and we can celebrate!
See https://docs.aws.amazon.com/ARG/latest/userguide/supported-resources.html
*/
if (Construct.isConstruct(this)) {
AppIdentity.taggedConstruct({ app }, this);
}
}
}
return Mixin;
}
47 changes: 47 additions & 0 deletions src/utils/mixin/app-aware-contruct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { SynthUtils } from "@aws-cdk/assert";
import type { BucketProps } from "@aws-cdk/aws-s3";
import { Bucket } from "@aws-cdk/aws-s3";
import type { GuStack } from "../../constructs/core";
import type { AppIdentity } from "../../constructs/core/identity";
import { simpleGuStackForTesting } from "../test";
import { GuAppAwareConstruct } from "./app-aware-construct";

// `GuAppAwareConstruct` should only operate if `props` has an `app` property,
// so usage here should be a no-op.
class TestConstruct extends GuAppAwareConstruct(Bucket) {
constructor(scope: GuStack, id: string, props: BucketProps) {
super(scope, id, props);
}
}

interface TestAppAwareConstructProps extends BucketProps, AppIdentity {}

class TestAppAwareConstruct extends GuAppAwareConstruct(Bucket) {
constructor(scope: GuStack, id: string, props: TestAppAwareConstructProps) {
super(scope, id, props);
}
}

describe("The GuAppAwareConstruct mixin", () => {
// demonstrates usage of `GuAppAwareConstruct`
it("should throw if no app identifier is provided", () => {
const stack = simpleGuStackForTesting();

expect(() => {
new TestConstruct(stack, "MyBucket", {});
}).toThrowError("Cannot use the GuAppAwareConstruct mixin without an AppIdentity");
});

it("should suffix the id with the app identifier", () => {
const stack = simpleGuStackForTesting();
const bucket = new TestAppAwareConstruct(stack, "MyBucket", { app: "Test" });

expect(bucket.idWithApp).toBe("MyBucketTest");
});

it("should add the app tag", () => {
const stack = simpleGuStackForTesting();
new TestAppAwareConstruct(stack, "MyBucket", { app: "Test" });
expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot();
});
});

0 comments on commit d9aeea8

Please sign in to comment.