cdk/lib/new-product-api.ts (240 lines of code) (raw):

import { GuApiGatewayWithLambdaByPath } from '@guardian/cdk'; import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch'; import type { GuStackProps } from '@guardian/cdk/lib/constructs/core'; import { GuStack } from '@guardian/cdk/lib/constructs/core'; import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda'; import type { App } from 'aws-cdk-lib'; import { Duration, Fn } from 'aws-cdk-lib'; import { ApiKey, CfnBasePathMapping, CfnDomainName, CfnUsagePlanKey, Cors, UsagePlan, } from 'aws-cdk-lib/aws-apigateway'; import { ComparisonOperator, Metric } from 'aws-cdk-lib/aws-cloudwatch'; import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { Runtime } from 'aws-cdk-lib/aws-lambda'; import { CfnRecordSet } from 'aws-cdk-lib/aws-route53'; export interface NewProductApiProps extends GuStackProps { domainName: string; hostedZoneId: string; certificateId: string; apiGatewayTargetDomainName: string; zuoraCatalogLocation: string; fulfilmentDateCalculatorS3Resource: string; } export class NewProductApi extends GuStack { constructor(scope: App, id: string, props: NewProductApiProps) { super(scope, id, props); // ---- Miscellaneous constants ---- // const isProd = this.stage === 'PROD'; const app = 'new-product-api'; const runtime = Runtime.JAVA_21; const fileName = 'new-product-api.jar'; const memorySize = 1536; const timeout = Duration.seconds(300); const environment = { Stage: this.stage, EmailQueueName: Fn.importValue(`comms-${this.stage}-EmailQueueName`), }; const sharedLambdaProps = { app, runtime, fileName, memorySize, timeout, environment, }; const alarmTopic = 'alarms-handler-topic-PROD'; // ---- API-triggered lambda functions ---- // const addSubscriptionLambda = new GuLambdaFunction( this, 'add-subscription', { handler: 'com.gu.newproduct.api.addsubscription.Handler::apply', functionName: `add-subscription-${this.stage}`, ...sharedLambdaProps, }, ); const productCatalogLambda = new GuLambdaFunction(this, 'product-catalog', { handler: 'com.gu.newproduct.api.productcatalog.Handler::apply', functionName: `product-catalog-${this.stage}`, ...sharedLambdaProps, }); // ---- API gateway ---- // const newProductApi = new GuApiGatewayWithLambdaByPath(this, { app, defaultCorsPreflightOptions: { allowOrigins: Cors.ALL_ORIGINS, allowMethods: Cors.ALL_METHODS, allowHeaders: ['Content-Type'], }, monitoringConfiguration: { noMonitoring: true }, targets: [ { path: '/add-subscription', httpMethod: 'POST', lambda: addSubscriptionLambda, apiKeyRequired: true, }, { path: '/product-catalog', httpMethod: 'GET', lambda: productCatalogLambda, apiKeyRequired: true, }, ], }); // ---- Alarms ---- // new GuAlarm(this, 'ApiGateway4XXAlarm', { app, alarmName: `new-product-api-${this.stage} API gateway 4XX response`, alarmDescription: 'New Product API received an invalid request', evaluationPeriods: 1, threshold: 6, actionsEnabled: isProd, snsTopicName: alarmTopic, comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: '4XXError', namespace: 'AWS/ApiGateway', statistic: 'Sum', period: Duration.seconds(900), dimensionsMap: { ApiName: `support-reminders-${this.stage}`, }, }), }); new GuAlarm(this, 'ApiGateway5XXAlarm', { app, alarmName: `new-product-api-${this.stage} 5XX error`, alarmDescription: `new-product-api-${this.stage} exceeded 1% 5XX error rate`, evaluationPeriods: 1, threshold: 1, actionsEnabled: isProd, snsTopicName: alarmTopic, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, metric: new Metric({ metricName: '5XXError', namespace: 'AWS/ApiGateway', statistic: 'Sum', period: Duration.seconds(60), dimensionsMap: { ApiName: `support-reminders-${this.stage}`, }, }), }); // ---- Usage plan and API key ---- // const usagePlan = new UsagePlan(this, 'NewProductUsagePlan', { name: `new-product-api-usage-plan-${this.stage}`, apiStages: [ { api: newProductApi.api, stage: newProductApi.api.deploymentStage, }, ], }); const apiKey = new ApiKey(this, 'NewProductApiKey', { apiKeyName: `new-product-api-key-${this.stage}`, description: 'Key required to call new product API', enabled: true, }); new CfnUsagePlanKey(this, 'NewProductUsagePlanKey', { keyId: apiKey.keyId, keyType: 'API_KEY', usagePlanId: usagePlan.usagePlanId, }); // ---- DNS ---- // const certificateArn = `arn:aws:acm:${this.region}:${this.account}:certificate/${props.certificateId}`; const cfnDomainName = new CfnDomainName(this, 'NewProductDomainName', { domainName: props.domainName, regionalCertificateArn: certificateArn, endpointConfiguration: { types: ['REGIONAL'], }, }); new CfnBasePathMapping(this, 'NewProductBasePathMapping', { domainName: cfnDomainName.ref, restApiId: newProductApi.api.restApiId, stage: newProductApi.api.deploymentStage.stageName, }); new CfnRecordSet(this, 'NewProductDNSRecord', { name: props.domainName, type: 'CNAME', hostedZoneId: props.hostedZoneId, ttl: '120', resourceRecords: [cfnDomainName.attrRegionalDomainName], }); // ---- Apply policies ---- // const cloudwatchLogsInlinePolicy = ( lambda: GuLambdaFunction, idPrefix: string, ): Policy => { return new Policy(this, `${idPrefix}-cloudwatch-logs-inline-policy`, { statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: [ 'logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents', ], resources: [ `arn:aws:logs:${this.region}:${this.account}:log-group:/aws/lambda/${lambda.functionName}:log-stream:*`, ], }), ], }); }; const addSubscriptionS3InlinePolicy: Policy = new Policy( this, 'add-subscription-s3-inline-policy', { statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: ['s3:GetObject'], resources: [ `arn:aws:s3:::gu-reader-revenue-private/membership/support-service-lambdas/${this.stage}/zuoraRest-${this.stage}*.json`, `arn:aws:s3:::gu-reader-revenue-private/membership/support-service-lambdas/${this.stage}/paperround-${this.stage}*.json`, ], }), ], }, ); const sharedS3InlinePolicy: Policy = new Policy( this, 'shared-s3-inline-policy', { statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: ['s3:GetObject'], resources: [ `arn:aws:s3:::fulfilment-date-calculator-${this.stage.toLowerCase()}/*`, `arn:aws:s3:::gu-zuora-catalog/${this.stage}/Zuora-${this.stage}/catalog.json`, ], }), ], }, ); const sqsInlinePolicy: Policy = new Policy(this, 'sqs-inline-policy', { statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: ['sqs:GetQueueUrl', 'sqs:SendMessage'], resources: [ `arn:aws:sqs:${this.region}:${this.account}:braze-emails-${this.stage}`, ], }), ], }); addSubscriptionLambda.role?.attachInlinePolicy( cloudwatchLogsInlinePolicy(addSubscriptionLambda, 'add-subscription'), ); addSubscriptionLambda.role?.attachInlinePolicy(sharedS3InlinePolicy); addSubscriptionLambda.role?.attachInlinePolicy( addSubscriptionS3InlinePolicy, ); addSubscriptionLambda.role?.attachInlinePolicy(sqsInlinePolicy); productCatalogLambda.role?.attachInlinePolicy( cloudwatchLogsInlinePolicy(productCatalogLambda, 'product-catalog'), ); productCatalogLambda.role?.attachInlinePolicy(sharedS3InlinePolicy); } }