From a43211e6efd3ad65f0bcf921224edd6076bf514c Mon Sep 17 00:00:00 2001 From: Jacob Winch Date: Thu, 12 May 2022 09:32:05 +0100 Subject: [PATCH] feat: add new pattern for API Gateway with routing to multiple Lambdas (#1250) * refactor!: renaming and reorganisation to make space for new API Lambda pattern BREAKING CHANGE: A small number of types/classes have been renamed or relocated. 1. The Http5xxAlarmProps type should now be imported from "@guardian/cdk/lib/constructs/cloudwatch" 2. The Gu5xxPercentageAlarm construct is renamed: GuAlb5xxPercentageAlarm 3. The Gu5xxPercentageAlarmProps type is renamed: GuAlb5xxPercentageAlarmProps * feat: add new pattern for API Gateway with routing to multiple Lambdas * docs: add better documentation for new pattern and explain use-cases --- .../api-gateway-alarms.test.ts.snap | 276 ++++ .../__snapshots__/ec2-alarms.test.ts.snap | 2 +- src/constructs/cloudwatch/alarm.ts | 11 +- .../cloudwatch/api-gateway-alarms.test.ts | 67 + .../cloudwatch/api-gateway-alarms.ts | 45 + src/constructs/cloudwatch/ec2-alarms.test.ts | 12 +- src/constructs/cloudwatch/ec2-alarms.ts | 17 +- .../api-multiple-lambdas.test.ts.snap | 1207 +++++++++++++++++ src/patterns/api-lambda.ts | 11 +- src/patterns/api-multiple-lambdas.test.ts | 82 ++ src/patterns/api-multiple-lambdas.ts | 138 ++ src/patterns/ec2-app/base.ts | 4 +- src/patterns/index.ts | 1 + 13 files changed, 1848 insertions(+), 25 deletions(-) create mode 100644 src/constructs/cloudwatch/__snapshots__/api-gateway-alarms.test.ts.snap create mode 100644 src/constructs/cloudwatch/api-gateway-alarms.test.ts create mode 100644 src/constructs/cloudwatch/api-gateway-alarms.ts create mode 100644 src/patterns/__snapshots__/api-multiple-lambdas.test.ts.snap create mode 100644 src/patterns/api-multiple-lambdas.test.ts create mode 100644 src/patterns/api-multiple-lambdas.ts diff --git a/src/constructs/cloudwatch/__snapshots__/api-gateway-alarms.test.ts.snap b/src/constructs/cloudwatch/__snapshots__/api-gateway-alarms.test.ts.snap new file mode 100644 index 0000000000..96ebc70a21 --- /dev/null +++ b/src/constructs/cloudwatch/__snapshots__/api-gateway-alarms.test.ts.snap @@ -0,0 +1,276 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The GuApiGateway5xxPercentageAlarm construct should create the correct alarm resource with minimal config 1`] = ` +Object { + "Outputs": Object { + "RestApiEndpoint0551178A": Object { + "Value": Object { + "Fn::Join": Array [ + "", + Array [ + "https://", + Object { + "Ref": "RestApi0C43BF4B", + }, + ".execute-api.", + Object { + "Ref": "AWS::Region", + }, + ".", + Object { + "Ref": "AWS::URLSuffix", + }, + "/", + Object { + "Ref": "RestApiDeploymentStageprod3855DE66", + }, + "/", + ], + ], + }, + }, + }, + "Resources": Object { + "ApiGatewayHigh5xxPercentageAlarmTesting67154503": Object { + "Properties": Object { + "ActionsEnabled": true, + "AlarmActions": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:sns:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":test-topic", + ], + ], + }, + ], + "AlarmDescription": "testing exceeded 1% error rate", + "AlarmName": "High 5XX error % from testing (ApiGateway) in TEST", + "ComparisonOperator": "GreaterThanThreshold", + "EvaluationPeriods": 1, + "Metrics": Array [ + Object { + "Expression": "100*m1/m2", + "Id": "expr_1", + "Label": "% of 5XX responses served for testing", + }, + Object { + "Id": "m1", + "MetricStat": Object { + "Metric": Object { + "Dimensions": Array [ + Object { + "Name": "ApiName", + "Value": "RestApi", + }, + ], + "MetricName": "5XXError", + "Namespace": "AWS/ApiGateway", + }, + "Period": 60, + "Stat": "Sum", + }, + "ReturnData": false, + }, + Object { + "Id": "m2", + "MetricStat": Object { + "Metric": Object { + "Dimensions": Array [ + Object { + "Name": "ApiName", + "Value": "RestApi", + }, + ], + "MetricName": "Count", + "Namespace": "AWS/ApiGateway", + }, + "Period": 60, + "Stat": "SampleCount", + }, + "ReturnData": false, + }, + ], + "Threshold": 1, + "TreatMissingData": "notBreaching", + }, + "Type": "AWS::CloudWatch::Alarm", + }, + "RestApi0C43BF4B": Object { + "Properties": Object { + "Name": "RestApi", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "RestApiAccount7C83CF5A": Object { + "DependsOn": Array [ + "RestApi0C43BF4B", + ], + "Properties": Object { + "CloudWatchRoleArn": Object { + "Fn::GetAtt": Array [ + "RestApiCloudWatchRoleE3ED6605", + "Arn", + ], + }, + }, + "Type": "AWS::ApiGateway::Account", + }, + "RestApiCloudWatchRoleE3ED6605": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "apigateway.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs", + ], + ], + }, + ], + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "RestApiDeployment180EC50354bd4ad342d73c9ba2d68e58585d62d5": Object { + "DependsOn": Array [ + "RestApiGET0F59260B", + "RestApitest9059D171", + ], + "Properties": Object { + "Description": "Automatically created by the RestApi construct", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "RestApiDeploymentStageprod3855DE66": Object { + "DependsOn": Array [ + "RestApiAccount7C83CF5A", + ], + "Properties": Object { + "DeploymentId": Object { + "Ref": "RestApiDeployment180EC50354bd4ad342d73c9ba2d68e58585d62d5", + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + "StageName": "prod", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ApiGateway::Stage", + }, + "RestApiGET0F59260B": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": Object { + "Type": "MOCK", + }, + "ResourceId": Object { + "Fn::GetAtt": Array [ + "RestApi0C43BF4B", + "RootResourceId", + ], + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "RestApitest9059D171": Object { + "Properties": Object { + "ParentId": Object { + "Fn::GetAtt": Array [ + "RestApi0C43BF4B", + "RootResourceId", + ], + }, + "PathPart": "test", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + }, +} +`; diff --git a/src/constructs/cloudwatch/__snapshots__/ec2-alarms.test.ts.snap b/src/constructs/cloudwatch/__snapshots__/ec2-alarms.test.ts.snap index bb9fdf88e2..c95d3a1387 100644 --- a/src/constructs/cloudwatch/__snapshots__/ec2-alarms.test.ts.snap +++ b/src/constructs/cloudwatch/__snapshots__/ec2-alarms.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`The Gu5xxPercentageAlarm construct should create the correct alarm resource with minimal config 1`] = ` +exports[`The GuAlb5xxPercentageAlarm construct should create the correct alarm resource with minimal config 1`] = ` Object { "Outputs": Object { "ApplicationLoadBalancerTestingDnsName": Object { diff --git a/src/constructs/cloudwatch/alarm.ts b/src/constructs/cloudwatch/alarm.ts index 2633654885..f0222200fc 100644 --- a/src/constructs/cloudwatch/alarm.ts +++ b/src/constructs/cloudwatch/alarm.ts @@ -9,6 +9,15 @@ export interface GuAlarmProps extends AlarmProps, AppIdentity { snsTopicName: string; } +export interface Http5xxAlarmProps + extends Omit< + GuAlarmProps, + "snsTopicName" | "evaluationPeriods" | "metric" | "period" | "threshold" | "treatMissingData" | "app" + > { + tolerated5xxPercentage: number; + numberOfMinutesAboveThresholdBeforeAlarm?: number; +} + /** * Creates a CloudWatch alarm which sends notifications to the specified SNS topic. * @@ -23,7 +32,7 @@ export interface GuAlarmProps extends AlarmProps, AppIdentity { * ``` * * This library provides an implementation of some commonly used alarms, which require less boilerplate than this construct, - * for example [[`Gu5xxPercentageAlarm`]]. Prefer using these more specific implementations where possible. + * for example the [[`GuAlb5xxPercentageAlarm`]]. Prefer using these more specific implementations where possible. */ export class GuAlarm extends Alarm { constructor(scope: GuStack, id: string, props: GuAlarmProps) { diff --git a/src/constructs/cloudwatch/api-gateway-alarms.test.ts b/src/constructs/cloudwatch/api-gateway-alarms.test.ts new file mode 100644 index 0000000000..dde7303a44 --- /dev/null +++ b/src/constructs/cloudwatch/api-gateway-alarms.test.ts @@ -0,0 +1,67 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { MockIntegration, RestApi } from "aws-cdk-lib/aws-apigateway"; +import { simpleGuStackForTesting } from "../../utils/test"; +import type { AppIdentity, GuStack } from "../core"; +import { GuApiGateway5xxPercentageAlarm } from "./api-gateway-alarms"; + +const app: AppIdentity = { + app: "testing", +}; + +function setupBasicRestApi(stack: GuStack): RestApi { + const restApi = new RestApi(stack, "RestApi", {}); + restApi.root.addResource("test"); + restApi.root.addMethod("GET", new MockIntegration()); + return restApi; +} + +describe("The GuApiGateway5xxPercentageAlarm construct", () => { + it("should create the correct alarm resource with minimal config", () => { + const stack = simpleGuStackForTesting(); + const props = { + tolerated5xxPercentage: 1, + snsTopicName: "test-topic", + }; + new GuApiGateway5xxPercentageAlarm(stack, { ...app, apiGatewayInstance: setupBasicRestApi(stack), ...props }); + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); + }); + + it("should use a custom description if one is provided", () => { + const stack = simpleGuStackForTesting(); + const props = { + alarmDescription: "test-custom-alarm-description", + tolerated5xxPercentage: 1, + snsTopicName: "test-topic", + }; + new GuApiGateway5xxPercentageAlarm(stack, { ...app, apiGatewayInstance: setupBasicRestApi(stack), ...props }); + Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { + AlarmDescription: "test-custom-alarm-description", + }); + }); + + it("should use a custom alarm name if one is provided", () => { + const stack = simpleGuStackForTesting(); + const props = { + alarmName: "test-custom-alarm-name", + tolerated5xxPercentage: 1, + snsTopicName: "test-topic", + }; + new GuApiGateway5xxPercentageAlarm(stack, { ...app, apiGatewayInstance: setupBasicRestApi(stack), ...props }); + Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { + AlarmName: "test-custom-alarm-name", + }); + }); + + it("should adjust the number of evaluation periods if a custom value is provided", () => { + const stack = simpleGuStackForTesting(); + const props = { + tolerated5xxPercentage: 1, + numberOfMinutesAboveThresholdBeforeAlarm: 3, + snsTopicName: "test-topic", + }; + new GuApiGateway5xxPercentageAlarm(stack, { ...app, apiGatewayInstance: setupBasicRestApi(stack), ...props }); + Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { + EvaluationPeriods: 3, + }); + }); +}); diff --git a/src/constructs/cloudwatch/api-gateway-alarms.ts b/src/constructs/cloudwatch/api-gateway-alarms.ts new file mode 100644 index 0000000000..da275cf210 --- /dev/null +++ b/src/constructs/cloudwatch/api-gateway-alarms.ts @@ -0,0 +1,45 @@ +import { Duration } from "aws-cdk-lib"; +import type { RestApi } from "aws-cdk-lib/aws-apigateway"; +import { ComparisonOperator, MathExpression, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch"; +import type { GuStack } from "../core"; +import { AppIdentity } from "../core"; +import type { GuAlarmProps, Http5xxAlarmProps } from "./alarm"; +import { GuAlarm } from "./alarm"; + +interface GuApiGateway5xxPercentageAlarmProps + extends Pick, + Http5xxAlarmProps, + AppIdentity { + apiGatewayInstance: RestApi; +} + +/** + * Creates an alarm which is triggered whenever the percentage of requests with a 5xx response code exceeds + * the specified threshold. + */ +export class GuApiGateway5xxPercentageAlarm extends GuAlarm { + constructor(scope: GuStack, props: GuApiGateway5xxPercentageAlarmProps) { + const mathExpression = new MathExpression({ + expression: "100*m1/m2", + usingMetrics: { + m1: props.apiGatewayInstance.metricServerError(), + m2: props.apiGatewayInstance.metricCount(), + }, + label: `% of 5XX responses served for ${props.app}`, + period: Duration.minutes(1), + }); + const defaultAlarmName = `High 5XX error % from ${props.app} (ApiGateway) in ${scope.stage}`; + const defaultDescription = `${props.app} exceeded ${props.tolerated5xxPercentage}% error rate`; + const alarmProps = { + ...props, + metric: mathExpression, + treatMissingData: TreatMissingData.NOT_BREACHING, + threshold: props.tolerated5xxPercentage, + comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, + alarmName: props.alarmName ?? defaultAlarmName, + alarmDescription: props.alarmDescription ?? defaultDescription, + evaluationPeriods: props.numberOfMinutesAboveThresholdBeforeAlarm ?? 1, + }; + super(scope, AppIdentity.suffixText(props, "ApiGatewayHigh5xxPercentageAlarm"), alarmProps); + } +} diff --git a/src/constructs/cloudwatch/ec2-alarms.test.ts b/src/constructs/cloudwatch/ec2-alarms.test.ts index 2a921c4f66..40fd994414 100644 --- a/src/constructs/cloudwatch/ec2-alarms.test.ts +++ b/src/constructs/cloudwatch/ec2-alarms.test.ts @@ -5,7 +5,7 @@ import { ApplicationListener, ApplicationProtocol } from "aws-cdk-lib/aws-elasti import { simpleGuStackForTesting } from "../../utils/test"; import type { AppIdentity } from "../core"; import { GuApplicationLoadBalancer, GuApplicationTargetGroup } from "../loadbalancing"; -import { Gu5xxPercentageAlarm, GuUnhealthyInstancesAlarm } from "./ec2-alarms"; +import { GuAlb5xxPercentageAlarm, GuUnhealthyInstancesAlarm } from "./ec2-alarms"; const vpc = Vpc.fromVpcAttributes(new Stack(), "VPC", { vpcId: "test", @@ -17,7 +17,7 @@ const app: AppIdentity = { app: "testing", }; -describe("The Gu5xxPercentageAlarm construct", () => { +describe("The GuAlb5xxPercentageAlarm construct", () => { it("should create the correct alarm resource with minimal config", () => { const stack = simpleGuStackForTesting(); const alb = new GuApplicationLoadBalancer(stack, "ApplicationLoadBalancer", { ...app, vpc }); @@ -25,7 +25,7 @@ describe("The Gu5xxPercentageAlarm construct", () => { tolerated5xxPercentage: 1, snsTopicName: "test-topic", }; - new Gu5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); + new GuAlb5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); }); @@ -37,7 +37,7 @@ describe("The Gu5xxPercentageAlarm construct", () => { tolerated5xxPercentage: 1, snsTopicName: "test-topic", }; - new Gu5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); + new GuAlb5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { AlarmDescription: "test-custom-alarm-description", }); @@ -51,7 +51,7 @@ describe("The Gu5xxPercentageAlarm construct", () => { tolerated5xxPercentage: 1, snsTopicName: "test-topic", }; - new Gu5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); + new GuAlb5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { AlarmName: "test-custom-alarm-name", }); @@ -65,7 +65,7 @@ describe("The Gu5xxPercentageAlarm construct", () => { numberOfMinutesAboveThresholdBeforeAlarm: 3, snsTopicName: "test-topic", }; - new Gu5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); + new GuAlb5xxPercentageAlarm(stack, { ...app, loadBalancer: alb, ...props }); Template.fromStack(stack).hasResourceProperties("AWS::CloudWatch::Alarm", { EvaluationPeriods: 3, }); diff --git a/src/constructs/cloudwatch/ec2-alarms.ts b/src/constructs/cloudwatch/ec2-alarms.ts index 5e3a3aea89..7f39b15080 100644 --- a/src/constructs/cloudwatch/ec2-alarms.ts +++ b/src/constructs/cloudwatch/ec2-alarms.ts @@ -5,18 +5,9 @@ import type { GuStack } from "../core"; import { AppIdentity } from "../core"; import type { GuApplicationLoadBalancer, GuApplicationTargetGroup } from "../loadbalancing"; import { GuAlarm } from "./alarm"; -import type { GuAlarmProps } from "./alarm"; +import type { GuAlarmProps, Http5xxAlarmProps } from "./alarm"; -export interface Http5xxAlarmProps - extends Omit< - GuAlarmProps, - "snsTopicName" | "evaluationPeriods" | "metric" | "period" | "threshold" | "treatMissingData" | "app" - > { - tolerated5xxPercentage: number; - numberOfMinutesAboveThresholdBeforeAlarm?: number; -} - -interface Gu5xxPercentageAlarmProps extends Pick, Http5xxAlarmProps, AppIdentity { +interface GuAlb5xxPercentageAlarmProps extends Pick, Http5xxAlarmProps, AppIdentity { loadBalancer: GuApplicationLoadBalancer; } @@ -28,8 +19,8 @@ interface GuUnhealthyInstancesAlarmProps extends Pick", + }, + }, + "Resources": Object { + "RestApi0C43BF4B": Object { + "Properties": Object { + "Name": "RestApi", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "RestApiAccount7C83CF5A": Object { + "DependsOn": Array [ + "RestApi0C43BF4B", + ], + "Properties": Object { + "CloudWatchRoleArn": Object { + "Fn::GetAtt": Array [ + "RestApiCloudWatchRoleE3ED6605", + "Arn", + ], + }, + }, + "Type": "AWS::ApiGateway::Account", + }, + "RestApiCloudWatchRoleE3ED6605": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "apigateway.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs", + ], + ], + }, + ], + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "RestApiDeployment180EC503e2055c66dc05268b5b65cc031aa8159e": Object { + "DependsOn": Array [ + "RestApitestalongpathGET4832AA08", + "RestApitestalongpath289D7A7A", + "RestApitestalong4FF54021", + "RestApitesta85B86F6B", + "RestApitestGET8B10FED0", + "RestApitestPOSTEE6B79A5", + "RestApitest9059D171", + ], + "Properties": Object { + "Description": "Automatically created by the RestApi construct", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "RestApiDeploymentStageprod3855DE66": Object { + "DependsOn": Array [ + "RestApiAccount7C83CF5A", + ], + "Properties": Object { + "DeploymentId": Object { + "Ref": "RestApiDeployment180EC503e2055c66dc05268b5b65cc031aa8159e", + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + "StageName": "prod", + "Tags": Array [ + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ApiGateway::Stage", + }, + "RestApitest9059D171": Object { + "Properties": Object { + "ParentId": Object { + "Fn::GetAtt": Array [ + "RestApi0C43BF4B", + "RootResourceId", + ], + }, + "PathPart": "test", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "RestApitestGET8B10FED0": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": Object { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":apigateway:", + Object { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + Object { + "Fn::GetAtt": Array [ + "lambdaoneA536F07A", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "ResourceId": Object { + "Ref": "RestApitest9059D171", + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "RestApitestGETApiPermissionTestRestApiF6F94545GETtest8BC9DFDB": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdaoneA536F07A", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/", + Object { + "Ref": "RestApiDeploymentStageprod3855DE66", + }, + "/GET/test", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "RestApitestGETApiPermissionTestTestRestApiF6F94545GETtest527F1C3C": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdaoneA536F07A", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/test-invoke-stage/GET/test", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "RestApitestPOSTApiPermissionTestRestApiF6F94545POSTtestD5FD5238": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdatwoAFC8CEF1", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/", + Object { + "Ref": "RestApiDeploymentStageprod3855DE66", + }, + "/POST/test", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "RestApitestPOSTApiPermissionTestTestRestApiF6F94545POSTtestAFABFBBC": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdatwoAFC8CEF1", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/test-invoke-stage/POST/test", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "RestApitestPOSTEE6B79A5": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "POST", + "Integration": Object { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":apigateway:", + Object { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + Object { + "Fn::GetAtt": Array [ + "lambdatwoAFC8CEF1", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "ResourceId": Object { + "Ref": "RestApitest9059D171", + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "RestApitesta85B86F6B": Object { + "Properties": Object { + "ParentId": Object { + "Ref": "RestApitest9059D171", + }, + "PathPart": "a", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "RestApitestalong4FF54021": Object { + "Properties": Object { + "ParentId": Object { + "Ref": "RestApitesta85B86F6B", + }, + "PathPart": "long", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "RestApitestalongpath289D7A7A": Object { + "Properties": Object { + "ParentId": Object { + "Ref": "RestApitestalong4FF54021", + }, + "PathPart": "path", + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "RestApitestalongpathGET4832AA08": Object { + "Properties": Object { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": Object { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":apigateway:", + Object { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + Object { + "Fn::GetAtt": Array [ + "lambdathreeD95CE916", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "ResourceId": Object { + "Ref": "RestApitestalongpath289D7A7A", + }, + "RestApiId": Object { + "Ref": "RestApi0C43BF4B", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "RestApitestalongpathGETApiPermissionTestRestApiF6F94545GETtestalongpathCF22897B": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdathreeD95CE916", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/", + Object { + "Ref": "RestApiDeploymentStageprod3855DE66", + }, + "/GET/test/a/long/path", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "RestApitestalongpathGETApiPermissionTestTestRestApiF6F94545GETtestalongpath6D81747A": Object { + "Properties": Object { + "Action": "lambda:InvokeFunction", + "FunctionName": Object { + "Fn::GetAtt": Array [ + "lambdathreeD95CE916", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":execute-api:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":", + Object { + "Ref": "RestApi0C43BF4B", + }, + "/test-invoke-stage/GET/test/a/long/path", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "lambdaoneA536F07A": Object { + "DependsOn": Array [ + "lambdaoneServiceRoleDefaultPolicy8CBB4D33", + "lambdaoneServiceRoleE392ADEE", + ], + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "DistributionBucketName", + }, + "S3Key": "test-stack/TEST/testing/my-app-1.zip", + }, + "Environment": Object { + "Variables": Object { + "APP": "testing", + "STACK": "test-stack", + "STAGE": "TEST", + }, + }, + "Handler": "handler.ts", + "MemorySize": 512, + "Role": Object { + "Fn::GetAtt": Array [ + "lambdaoneServiceRoleE392ADEE", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "lambdaoneServiceRoleDefaultPolicy8CBB4D33": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + "/*", + ], + ], + }, + ], + }, + Object { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing", + ], + ], + }, + }, + Object { + "Action": Array [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "lambdaoneServiceRoleDefaultPolicy8CBB4D33", + "Roles": Array [ + Object { + "Ref": "lambdaoneServiceRoleE392ADEE", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "lambdaoneServiceRoleE392ADEE": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "lambdathreeD95CE916": Object { + "DependsOn": Array [ + "lambdathreeServiceRoleDefaultPolicy6BB3377A", + "lambdathreeServiceRole04701A6A", + ], + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "DistributionBucketName", + }, + "S3Key": "test-stack/TEST/testing/my-app-3.zip", + }, + "Environment": Object { + "Variables": Object { + "APP": "testing", + "STACK": "test-stack", + "STAGE": "TEST", + }, + }, + "Handler": "handler.ts", + "MemorySize": 512, + "Role": Object { + "Fn::GetAtt": Array [ + "lambdathreeServiceRole04701A6A", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "lambdathreeServiceRole04701A6A": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "lambdathreeServiceRoleDefaultPolicy6BB3377A": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + "/*", + ], + ], + }, + ], + }, + Object { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing", + ], + ], + }, + }, + Object { + "Action": Array [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "lambdathreeServiceRoleDefaultPolicy6BB3377A", + "Roles": Array [ + Object { + "Ref": "lambdathreeServiceRole04701A6A", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "lambdatwoAFC8CEF1": Object { + "DependsOn": Array [ + "lambdatwoServiceRoleDefaultPolicy36A183D7", + "lambdatwoServiceRole49D91DB3", + ], + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "DistributionBucketName", + }, + "S3Key": "test-stack/TEST/testing/my-app-2.zip", + }, + "Environment": Object { + "Variables": Object { + "APP": "testing", + "STACK": "test-stack", + "STAGE": "TEST", + }, + }, + "Handler": "handler.ts", + "MemorySize": 512, + "Role": Object { + "Fn::GetAtt": Array [ + "lambdatwoServiceRole49D91DB3", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "lambdatwoServiceRole49D91DB3": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + "Tags": Array [ + Object { + "Key": "App", + "Value": "testing", + }, + Object { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + Object { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + Object { + "Key": "Stack", + "Value": "test-stack", + }, + Object { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "lambdatwoServiceRoleDefaultPolicy36A183D7": Object { + "Properties": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + ], + ], + }, + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":s3:::", + Object { + "Ref": "DistributionBucketName", + }, + "/*", + ], + ], + }, + ], + }, + Object { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing", + ], + ], + }, + }, + Object { + "Action": Array [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": Object { + "Fn::Join": Array [ + "", + Array [ + "arn:aws:ssm:", + Object { + "Ref": "AWS::Region", + }, + ":", + Object { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/testing/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "lambdatwoServiceRoleDefaultPolicy36A183D7", + "Roles": Array [ + Object { + "Ref": "lambdatwoServiceRole49D91DB3", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, +} +`; diff --git a/src/patterns/api-lambda.ts b/src/patterns/api-lambda.ts index 530b466576..ca223a8ac2 100644 --- a/src/patterns/api-lambda.ts +++ b/src/patterns/api-lambda.ts @@ -9,7 +9,7 @@ interface ApiProps extends Omit { id: string; } -interface GuApiLambdaProps extends Omit { +export interface GuApiLambdaProps extends Omit { /** * A list of [[`LambdaRestApiProps`]] to configure for the lambda. */ @@ -36,8 +36,15 @@ interface GuApiLambdaProps extends Omit; diff --git a/src/patterns/api-multiple-lambdas.test.ts b/src/patterns/api-multiple-lambdas.test.ts new file mode 100644 index 0000000000..ee242857b5 --- /dev/null +++ b/src/patterns/api-multiple-lambdas.test.ts @@ -0,0 +1,82 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { Runtime } from "aws-cdk-lib/aws-lambda"; +import { GuLambdaFunction } from "../constructs/lambda"; +import { GuTemplate, simpleGuStackForTesting } from "../utils/test"; +import { GuApiGatewayWithLambdaByPath } from "./api-multiple-lambdas"; + +describe("The GuApiGatewayWithLambdaByPath pattern", () => { + it("should create the correct resources with minimal config", () => { + const stack = simpleGuStackForTesting(); + const defaultProps = { + handler: "handler.ts", + runtime: Runtime.NODEJS_14_X, + app: "testing", + }; + const lambdaOne = new GuLambdaFunction(stack, "lambda-one", { + ...defaultProps, + fileName: "my-app-1.zip", + }); + const lambdaTwo = new GuLambdaFunction(stack, "lambda-two", { + ...defaultProps, + fileName: "my-app-2.zip", + }); + const lambdaThree = new GuLambdaFunction(stack, "lambda-three", { + ...defaultProps, + fileName: "my-app-3.zip", + }); + new GuApiGatewayWithLambdaByPath(stack, { + app: "testing", + monitoringConfiguration: { noMonitoring: true }, + targets: [ + { + path: "/test", + httpMethod: "GET", + lambda: lambdaOne, + }, + { + path: "/test", + httpMethod: "POST", + lambda: lambdaTwo, + }, + { + path: "/test/a/long/path", + httpMethod: "GET", + lambda: lambdaThree, + }, + ], + }); + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); + }); + + it("should create an alarm if the relevant monitoring configuration is provided", () => { + const stack = simpleGuStackForTesting(); + const lambdaOne = new GuLambdaFunction(stack, "lambda-one", { + handler: "handler.ts", + runtime: Runtime.NODEJS_14_X, + app: "testing", + fileName: "my-app-1.zip", + }); + new GuApiGatewayWithLambdaByPath(stack, { + app: "testing", + monitoringConfiguration: { + snsTopicName: "my-alarm-topic", + http5xxAlarm: { + tolerated5xxPercentage: 1, + numberOfMinutesAboveThresholdBeforeAlarm: 3, + }, + }, + targets: [ + { + path: "/test", + httpMethod: "GET", + lambda: lambdaOne, + }, + ], + }); + //The shape of this alarm is tested at construct level + GuTemplate.fromStack(stack).hasResourceWithLogicalId( + "AWS::CloudWatch::Alarm", + /^ApiGatewayHigh5xxPercentageAlarm.+/ + ); + }); +}); diff --git a/src/patterns/api-multiple-lambdas.ts b/src/patterns/api-multiple-lambdas.ts new file mode 100644 index 0000000000..1bdc18dd5b --- /dev/null +++ b/src/patterns/api-multiple-lambdas.ts @@ -0,0 +1,138 @@ +import type { RestApiProps } from "aws-cdk-lib/aws-apigateway"; +import { LambdaIntegration, RestApi } from "aws-cdk-lib/aws-apigateway"; +import type { Http5xxAlarmProps, NoMonitoring } from "../constructs/cloudwatch"; +import { GuApiGateway5xxPercentageAlarm } from "../constructs/cloudwatch/api-gateway-alarms"; +import type { AppIdentity, GuStack } from "../constructs/core"; +import type { GuLambdaFunction } from "../constructs/lambda"; + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods +export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH"; + +export interface ApiTarget { + /** + * The path for the request (e.g. /test). + */ + path: string; + /** + * The [[`HttpMethod`]] for the target (e.g. GET, POST, PUT). + */ + httpMethod: HttpMethod; + /** + * The Lambda function responsible for handling the request. + */ + lambda: GuLambdaFunction; +} + +/** + * Configuration for an alarm based on the percentage of 5XX responses served by the API Gateway instance. + * + * For example: + * + * ```typescript + * monitoringConfiguration: { + * snsTopicName: "my-topic-for-cloudwatch-alerts", + * http5xxAlarm: { + * tolerated5xxPercentage: , + * } + * } + * ``` + */ +export interface ApiGatewayAlarms { + snsTopicName: string; + http5xxAlarm: Http5xxAlarmProps; + noMonitoring?: false; +} + +export interface GuApiGatewayWithLambdaByPathProps extends RestApiProps, AppIdentity { + /** + * A list of [[`ApiTarget`]]s to configure for the API Gateway instance. + */ + targets: ApiTarget[]; + /** + * Alarm configuration for your API. For more details, see [[`ApiGatewayAlarms`]]. + * + * If your team do not use CloudWatch, it's possible to opt-out with the following configuration: + * ```typescript + * monitoringConfiguration: { noMonitoring: true } + * ``` + */ + monitoringConfiguration: NoMonitoring | ApiGatewayAlarms; +} + +function isNoMonitoring( + monitoringConfiguration: NoMonitoring | ApiGatewayAlarms +): monitoringConfiguration is NoMonitoring { + return (monitoringConfiguration).noMonitoring; +} + +/** + * A pattern to create an API Gateway instance which uses path-based routing to route requests + * to two or more Lambda functions. + * + * This pattern should be used if you need to configure path-based routing to serve different + * requests with different Lambdas. If you intend to serve all traffic via a single Lambda, use + * the [[`GuApiLambda`]] pattern instead. + * + * Example usage: + * + * ```typescript + * // Configure lambdas + * const lambdaOne = new GuLambdaFunction(this, "lambda-one", { + * app: "example-lambda-one", + * runtime: Runtime.NODEJS_14_X, + * handler: "lambda-one.handler", + * fileName: "lambda-one.zip", + * }); + * const lambdaTwo = new GuLambdaFunction(this, "lambda-two", { + * app: "example-lambda-two", + * runtime: Runtime.NODEJS_14_X, + * handler: "lambda-two.handler", + * fileName: "lambda-two.zip", + * }); + * + * // Wire up the API + * new GuApiGatewayWithLambdaByPath(this, { + * app: "example-api-gateway-instance", + * targets: [ + * { + * path: "lambda-one", + * httpMethod: "GET", + * lambda: lambdaOne, + * }, + * { + * path: "lambda-two", + * httpMethod: "GET", + * lambda: lambdaTwo, + * }, + * ], + * // Create an alarm + * monitoringConfiguration: { + * snsTopicName: "my-topic-for-cloudwatch-alerts", + * http5xxAlarm: { + * tolerated5xxPercentage: 1, + * } + * } + * }); + * ``` + * + * For all API configuration options, see [[`GuApiGatewayWithLambdaByPathProps`]]. + * + * For details on configuring the individual Lambda functions, see [[`GuLambdaFunction`]]. + */ +export class GuApiGatewayWithLambdaByPath { + constructor(scope: GuStack, props: GuApiGatewayWithLambdaByPathProps) { + const apiGateway = new RestApi(scope, "RestApi", props); + props.targets.map((target) => { + const resource = apiGateway.root.resourceForPath(target.path); + resource.addMethod(target.httpMethod, new LambdaIntegration(target.lambda)); + }); + if (!isNoMonitoring(props.monitoringConfiguration)) { + new GuApiGateway5xxPercentageAlarm(scope, { + app: props.app, + apiGatewayInstance: apiGateway, + snsTopicName: props.monitoringConfiguration.snsTopicName, + ...props.monitoringConfiguration.http5xxAlarm, + }); + } + } +} diff --git a/src/patterns/ec2-app/base.ts b/src/patterns/ec2-app/base.ts index 3f855f8490..66e060cdbd 100644 --- a/src/patterns/ec2-app/base.ts +++ b/src/patterns/ec2-app/base.ts @@ -10,7 +10,7 @@ import { GuCertificate } from "../../constructs/acm"; import type { GuUserDataProps } from "../../constructs/autoscaling"; import { GuAutoScalingGroup, GuUserData } from "../../constructs/autoscaling"; import type { Http5xxAlarmProps, NoMonitoring } from "../../constructs/cloudwatch"; -import { Gu5xxPercentageAlarm, GuUnhealthyInstancesAlarm } from "../../constructs/cloudwatch"; +import { GuAlb5xxPercentageAlarm, GuUnhealthyInstancesAlarm } from "../../constructs/cloudwatch"; import type { GuStack } from "../../constructs/core"; import { AppIdentity, GuLoggingStreamNameParameter, GuStringParameter } from "../../constructs/core"; import { GuSecurityGroup, GuVpc, SubnetType } from "../../constructs/ec2"; @@ -419,7 +419,7 @@ export class GuEc2App { const { http5xxAlarm, snsTopicName, unhealthyInstancesAlarm } = monitoringConfiguration; if (http5xxAlarm) { - new Gu5xxPercentageAlarm(scope, { + new GuAlb5xxPercentageAlarm(scope, { app, loadBalancer, snsTopicName, diff --git a/src/patterns/index.ts b/src/patterns/index.ts index 66a6308899..60fb65d169 100644 --- a/src/patterns/index.ts +++ b/src/patterns/index.ts @@ -1,4 +1,5 @@ export * from "./api-lambda"; +export * from "./api-multiple-lambdas"; export * from "./scheduled-lambda"; export * from "./sns-lambda"; export * from "./kinesis-lambda";