diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md b/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md index 6dd9de9e4e475..cce77fd6398e6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/README.md @@ -21,6 +21,8 @@ - [Lambda Integration](#lambda) - [HTTP Proxy Integration](#http-proxy) - [Private Integration](#private-integration) +- [WebSocket APIs](#websocket-apis) + - [Lambda WebSocket Integration](#lambda-websocket-integration) ## HTTP APIs @@ -146,3 +148,32 @@ const httpEndpoint = new HttpApi(stack, 'HttpProxyPrivateApi', { }), }); ``` + +## WebSocket APIs + +WebSocket integrations connect a route to backend resources. The following integrations are supported in the CDK. + +### Lambda WebSocket Integration + +Lambda integrations enable integrating a WebSocket API route with a Lambda function. When a client connects/disconnects +or sends message specific to a route, the API Gateway service forwards the request to the Lambda function + +The API Gateway service will invoke the lambda function with an event payload of a specific format. + +The following code configures a `sendmessage` route with a Lambda integration + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi'); +new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); + +const messageHandler = new lambda.Function(stack, 'MessageHandler', {...}); +webSocketApi.addRoute('sendmessage', { + integration: new LambdaWebSocketIntegration({ + handler: connectHandler, + }), +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts index c202386ae710e..fd16aff655ff2 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/index.ts @@ -1 +1,2 @@ export * from './http'; +export * from './websocket'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts new file mode 100644 index 0000000000000..04a64da0c7540 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/index.ts @@ -0,0 +1 @@ +export * from './lambda'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts new file mode 100644 index 0000000000000..85e199a71c3d7 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/lib/websocket/lambda.ts @@ -0,0 +1,44 @@ +import { + IWebSocketRouteIntegration, + WebSocketIntegrationType, + WebSocketRouteIntegrationBindOptions, + WebSocketRouteIntegrationConfig, +} from '@aws-cdk/aws-apigatewayv2'; +import { ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; +import { Names, Stack } from '@aws-cdk/core'; + +/** + * Lambda WebSocket Integration props + */ +export interface LambdaWebSocketIntegrationProps { + /** + * The handler for this integration. + */ + readonly handler: IFunction +} + +/** + * Lambda WebSocket Integration + */ +export class LambdaWebSocketIntegration implements IWebSocketRouteIntegration { + constructor(private props: LambdaWebSocketIntegrationProps) {} + + bind(options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + const route = options.route; + this.props.handler.addPermission(`${Names.nodeUniqueId(route.node)}-Permission`, { + scope: options.scope, + principal: new ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: Stack.of(route).formatArn({ + service: 'execute-api', + resource: route.webSocketApi.apiId, + resourceName: `*/*${route.routeKey}`, + }), + }); + + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: this.props.handler.functionArn, + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json new file mode 100644 index 0000000000000..48bf164ada435 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json @@ -0,0 +1,534 @@ +{ + "Resources": { + "ConnectHandlerServiceRole7E4A9B1F": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "ConnectHandler2FFD52D8": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"connected\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "ConnectHandlerServiceRole7E4A9B1F", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "ConnectHandlerServiceRole7E4A9B1F" + ] + }, + "DisconnectHandlerServiceRoleE54F14F9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "DisconnectHandlerCB7ED6F7": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"disconnected\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "DisconnectHandlerServiceRoleE54F14F9", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "DisconnectHandlerServiceRoleE54F14F9" + ] + }, + "DefaultHandlerServiceRoleDF00569C": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "DefaultHandler604DF7AC": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"default\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "DefaultHandlerServiceRoleDF00569C", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "DefaultHandlerServiceRoleDF00569C" + ] + }, + "MessageHandlerServiceRoleDF05266A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MessageHandlerDFBBCD6B": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: \"received\" }; };" + }, + "Role": { + "Fn::GetAtt": [ + "MessageHandlerServiceRoleDF05266A", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "MessageHandlerServiceRoleDF05266A" + ] + }, + "mywsapi32E6CE11": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "mywsapi", + "ProtocolType": "WEBSOCKET", + "RouteSelectionExpression": "$request.body.action" + } + }, + "mywsapiconnectRouteWebSocketApiIntegmywsapiconnectRoute456CB290Permission2D0BC294": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ConnectHandler2FFD52D8", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$connect" + ] + ] + } + } + }, + "mywsapiconnectRouteWebSocketIntegration50b017444a02be00a0b575d123314581176017EE": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "ConnectHandler2FFD52D8", + "Arn" + ] + } + } + }, + "mywsapiconnectRoute45A0ED6A": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$connect", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapiconnectRouteWebSocketIntegration50b017444a02be00a0b575d123314581176017EE" + } + ] + ] + } + } + }, + "mywsapidisconnectRouteWebSocketApiIntegmywsapidisconnectRoute26B84CF3PermissionB3F6D0A8": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "DisconnectHandlerCB7ED6F7", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$disconnect" + ] + ] + } + } + }, + "mywsapidisconnectRouteWebSocketIntegrationcd3bacb451e82549501e141cc094d7ba1F7F68BC": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "DisconnectHandlerCB7ED6F7", + "Arn" + ] + } + } + }, + "mywsapidisconnectRoute421A8CB9": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$disconnect", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapidisconnectRouteWebSocketIntegrationcd3bacb451e82549501e141cc094d7ba1F7F68BC" + } + ] + ] + } + } + }, + "mywsapidefaultRouteWebSocketApiIntegmywsapidefaultRouteA13D926BPermission58B64FCE": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "DefaultHandler604DF7AC", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*$default" + ] + ] + } + } + }, + "mywsapidefaultRouteWebSocketIntegration640ac0772c157aa8b9a56aa99adbd9d7A2B7F2FA": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "DefaultHandler604DF7AC", + "Arn" + ] + } + } + }, + "mywsapidefaultRouteE9382DF8": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "$default", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapidefaultRouteWebSocketIntegration640ac0772c157aa8b9a56aa99adbd9d7A2B7F2FA" + } + ] + ] + } + } + }, + "mywsapisendmessageRouteWebSocketApiIntegmywsapisendmessageRoute8A775F3CPermission660FB575": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "MessageHandlerDFBBCD6B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "mywsapi32E6CE11" + }, + "/*/*sendmessage" + ] + ] + } + } + }, + "mywsapisendmessageRouteWebSocketIntegrationcf58a195e318f43f52c4d9ac6d6d2430786B6471": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "MessageHandlerDFBBCD6B", + "Arn" + ] + } + } + }, + "mywsapisendmessageRouteAE873328": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "RouteKey": "sendmessage", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "mywsapisendmessageRouteWebSocketIntegrationcf58a195e318f43f52c4d9ac6d6d2430786B6471" + } + ] + ] + } + } + }, + "mystage114C35EC": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "mywsapi32E6CE11" + }, + "StageName": "dev", + "AutoDeploy": true + } + } + }, + "Outputs": { + "ApiEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "wss://", + { + "Ref": "mywsapi32E6CE11" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/dev" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts new file mode 100644 index 0000000000000..01e25f906b0f8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.ts @@ -0,0 +1,54 @@ +import { WebSocketApi, WebSocketStage } from '@aws-cdk/aws-apigatewayv2'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { LambdaWebSocketIntegration } from '../../lib'; + +/* + * Stack verification steps: + * 1. Connect: 'wscat -c '. Should connect successfully and print event data containing connectionId in cloudwatch + * 2. SendMessage: '> {"action": "sendmessage", "data": "some-data"}'. Should send the message successfully and print the data in cloudwatch + * 3. Default: '> {"data": "some-data"}'. Should send the message successfully and print the data in cloudwatch + * 4. Disconnect: disconnect from the wscat. Should print event data containing connectionId in cloudwatch + */ + +const app = new App(); +const stack = new Stack(app, 'WebSocketApiInteg'); + +const connectHandler = new lambda.Function(stack, 'ConnectHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "connected" }; };'), +}); + +const disconnetHandler = new lambda.Function(stack, 'DisconnectHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "disconnected" }; };'), +}); + +const defaultHandler = new lambda.Function(stack, 'DefaultHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "default" }; };'), +}); + +const messageHandler = new lambda.Function(stack, 'MessageHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { console.log(event); return { statusCode: 200, body: "received" }; };'), +}); + +const webSocketApi = new WebSocketApi(stack, 'mywsapi', { + connectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: connectHandler }) }, + disconnectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: disconnetHandler }) }, + defaultRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: defaultHandler }) }, +}); +const stage = new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); + +webSocketApi.addRoute('sendmessage', { integration: new LambdaWebSocketIntegration({ handler: messageHandler }) }); + +new CfnOutput(stack, 'ApiEndpoint', { value: stage.url }); diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts new file mode 100644 index 0000000000000..5f431ca28fc49 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/lambda.test.ts @@ -0,0 +1,35 @@ +import '@aws-cdk/assert/jest'; +import { WebSocketApi } from '@aws-cdk/aws-apigatewayv2'; +import { Code, Function, Runtime } from '@aws-cdk/aws-lambda'; +import { Stack } from '@aws-cdk/core'; +import { LambdaWebSocketIntegration } from '../../lib'; + + +describe('LambdaWebSocketIntegration', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const fooFn = fooFunction(stack, 'Fn'); + + // WHEN + new WebSocketApi(stack, 'Api', { + connectRouteOptions: { + integration: new LambdaWebSocketIntegration({ handler: fooFn }), + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + IntegrationType: 'AWS_PROXY', + IntegrationUri: stack.resolve(fooFn.functionArn), + }); + }); +}); + +function fooFunction(stack: Stack, id: string) { + return new Function(stack, id, { + code: Code.fromInline('foo'), + runtime: Runtime.NODEJS_12_X, + handler: 'index.handler', + }); +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index 4da900f271e8f..d8278a800a00f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -7,7 +7,7 @@ Features | Stability -------------------------------------------|-------------------------------------------------------- CFN Resources | ![Stable](https://img.shields.io/badge/stable-success.svg?style=for-the-badge) Higher level constructs for HTTP APIs | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) -Higher level constructs for Websocket APIs | ![Not Implemented](https://img.shields.io/badge/not--implemented-black.svg?style=for-the-badge) +Higher level constructs for Websocket APIs | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) > **CFN Resources:** All classes with the `Cfn` prefix in this module ([CFN Resources]) are always > stable and safe to use. @@ -38,6 +38,7 @@ Higher level constructs for Websocket APIs | ![Not Implemented](https://img.shie - [Metrics](#metrics) - [VPC Link](#vpc-link) - [Private Integration](#private-integration) +- [WebSocket API](#websocket-api) ## Introduction @@ -230,7 +231,7 @@ API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-acces These authorizers can be found in the [APIGatewayV2-Authorizers](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigatewayv2-authorizers-readme.html) constructs library. -## Metrics +### Metrics The API Gateway v2 service sends metrics around the performance of HTTP APIs to Amazon CloudWatch. These metrics can be referred to using the metric APIs available on the `HttpApi` construct. @@ -277,3 +278,46 @@ Amazon ECS container-based applications. Using private integrations, resources clients outside of the VPC. These integrations can be found in the [APIGatewayV2-Integrations](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigatewayv2-integrations-readme.html) constructs library. + +## WebSocket API + +A WebSocket API in API Gateway is a collection of WebSocket routes that are integrated with backend HTTP endpoints, +Lambda functions, or other AWS services. You can use API Gateway features to help you with all aspects of the API +lifecycle, from creation through monitoring your production APIs. [Read more](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-overview.html) + +WebSocket APIs have two fundamental concepts - Routes and Integrations. + +WebSocket APIs direct JSON messages to backend integrations based on configured routes. (Non-JSON messages are directed +to the configured `$default` route.) + +Integrations define how the WebSocket API behaves when a client reaches a specific Route. Learn more at +[Configuring integrations](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-integration-requests.html). + +Integrations are available in the `aws-apigatewayv2-integrations` module and more information is available in that module. + +To add the default WebSocket routes supported by API Gateway (`$connect`, `$disconnect` and `$default`), configure them as part of api props: + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi', { + connectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: connectHandler }) }, + disconnectRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: disconnetHandler }) }, + defaultRouteOptions: { integration: new LambdaWebSocketIntegration({ handler: defaultHandler }) }, +}); + +new WebSocketStage(stack, 'mystage', { + webSocketApi, + stageName: 'dev', + autoDeploy: true, +}); +``` + +To add any other route: + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi'); +webSocketApi.addRoute('sendmessage', { + integration: new LambdaWebSocketIntegration({ + handler: messageHandler, + }), +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts index d843b51f8b315..adbe3fe3efc2c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts @@ -1,4 +1,10 @@ -import { IResource } from '@aws-cdk/core'; +import { IResource, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnApiMapping, CfnApiMappingProps } from '../apigatewayv2.generated'; +import { HttpApi } from '../http/api'; +import { IApi } from './api'; +import { IDomainName } from './domain-name'; +import { IStage } from './stage'; /** * Represents an ApiGatewayV2 ApiMapping resource @@ -11,3 +17,109 @@ export interface IApiMapping extends IResource { */ readonly apiMappingId: string; } + +/** + * Properties used to create the ApiMapping resource + */ +export interface ApiMappingProps { + /** + * Api mapping key. The path where this stage should be mapped to on the domain + * @default - undefined for the root path mapping. + */ + readonly apiMappingKey?: string; + + /** + * The Api to which this mapping is applied + */ + readonly api: IApi; + + /** + * custom domain name of the mapping target + */ + readonly domainName: IDomainName; + + /** + * stage for the ApiMapping resource + * required for WebSocket API + * defaults to default stage of an HTTP API + * + * @default - Default stage of the passed API for HTTP API, required for WebSocket API + */ + readonly stage?: IStage; +} + +/** + * The attributes used to import existing ApiMapping + */ +export interface ApiMappingAttributes { + /** + * The API mapping ID + */ + readonly apiMappingId: string; +} + +/** + * Create a new API mapping for API Gateway API endpoint. + * @resource AWS::ApiGatewayV2::ApiMapping + */ +export class ApiMapping extends Resource implements IApiMapping { + /** + * import from API ID + */ + public static fromApiMappingAttributes(scope: Construct, id: string, attrs: ApiMappingAttributes): IApiMapping { + class Import extends Resource implements IApiMapping { + public readonly apiMappingId = attrs.apiMappingId; + } + return new Import(scope, id); + } + /** + * ID of the API Mapping + */ + public readonly apiMappingId: string; + + /** + * API Mapping key + */ + public readonly mappingKey?: string; + + constructor(scope: Construct, id: string, props: ApiMappingProps) { + super(scope, id); + + let stage = props.stage; + if (!stage) { + if (props.api instanceof HttpApi) { + if (props.api.defaultStage) { + stage = props.api.defaultStage; + } else { + throw new Error('stage is required if default stage is not available'); + } + } else { + throw new Error('stage is required for WebSocket API'); + } + } + + const paramRe = '^[a-zA-Z0-9]*[-_.+!,$]?[a-zA-Z0-9]*$'; + if (props.apiMappingKey && !new RegExp(paramRe).test(props.apiMappingKey)) { + throw new Error('An ApiMapping key may contain only letters, numbers and one of $-_.+!*\'(),'); + } + + if (props.apiMappingKey === '') { + throw new Error('empty string for api mapping key not allowed'); + } + + const apiMappingProps: CfnApiMappingProps = { + apiId: props.api.apiId, + domainName: props.domainName.name, + stage: stage.stageName, + apiMappingKey: props.apiMappingKey, + }; + + const resource = new CfnApiMapping(this, 'Resource', apiMappingProps); + + // ensure the dependency on the provided stage + this.node.addDependency(stage); + + this.apiMappingId = resource.ref; + this.mappingKey = props.apiMappingKey; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts new file mode 100644 index 0000000000000..c632e6309083d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api.ts @@ -0,0 +1,71 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { IResource } from '@aws-cdk/core'; + +/** + * Represents a API Gateway HTTP/WebSocket API + */ +export interface IApi extends IResource { + /** + * The identifier of this API Gateway API. + * @attribute + */ + readonly apiId: string; + + /** + * The default endpoint for an API + * @attribute + */ + readonly apiEndpoint: string; + + /** + * Return the given named metric for this Api Gateway + * + * @default - average over 5 minutes + */ + metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the number of client-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the number of server-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the amount of data processed in bytes. + * + * @default - sum over 5 minutes + */ + metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the total number API requests in a given period. + * + * @default - SampleCount over 5 minutes + */ + metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * Metric for the time between when API Gateway relays a request to the backend + * and when it receives a response from the backend. + * + * @default - no statistic + */ + metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; + + /** + * The time between when API Gateway receives a request from a client + * and when it returns a response to the client. + * The latency includes the integration latency and other API Gateway overhead. + * + * @default - no statistic + */ + metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts new file mode 100644 index 0000000000000..542fcfb16f8f4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/base.ts @@ -0,0 +1,111 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; +import { Resource } from '@aws-cdk/core'; +import { IntegrationCache } from '../private/integration-cache'; +import { IApi } from './api'; +import { ApiMapping } from './api-mapping'; +import { DomainMappingOptions, IStage } from './stage'; + +/** + * Base class representing an API + * @internal + */ +export abstract class ApiBase extends Resource implements IApi { + abstract readonly apiId: string; + abstract readonly apiEndpoint: string; + /** + * @internal + */ + protected _integrationCache: IntegrationCache = new IntegrationCache(); + + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/ApiGateway', + metricName, + dimensions: { ApiId: this.apiId }, + ...props, + }).attachTo(this); + } + + public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('4XXError', { statistic: 'Sum', ...props }); + } + + public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('5XXError', { statistic: 'Sum', ...props }); + } + + public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('DataProcessed', { statistic: 'Sum', ...props }); + } + + public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Count', { statistic: 'SampleCount', ...props }); + } + + public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('IntegrationLatency', props); + } + + public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Latency', props); + } +} + + +/** + * Base class representing a Stage + * @internal + */ +export abstract class StageBase extends Resource implements IStage { + public abstract readonly stageName: string; + protected abstract readonly baseApi: IApi; + + /** + * The URL to this stage. + */ + abstract get url(): string; + + /** + * @internal + */ + protected _addDomainMapping(domainMapping: DomainMappingOptions) { + new ApiMapping(this, `${domainMapping.domainName}${domainMapping.mappingKey}`, { + api: this.baseApi, + domainName: domainMapping.domainName, + stage: this, + apiMappingKey: domainMapping.mappingKey, + }); + // ensure the dependency + this.node.addDependency(domainMapping.domainName); + } + + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.baseApi.metric(metricName, props).with({ + dimensions: { ApiId: this.baseApi.apiId, Stage: this.stageName }, + }).attachTo(this); + } + + public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('4XXError', { statistic: 'Sum', ...props }); + } + + public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('5XXError', { statistic: 'Sum', ...props }); + } + + public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('DataProcessed', { statistic: 'Sum', ...props }); + } + + public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Count', { statistic: 'SampleCount', ...props }); + } + + public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('IntegrationLatency', props); + } + + public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { + return this.metric('Latency', props); + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts index eeb237a4e7f84..b0a0f1c0265eb 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts @@ -1,3 +1,4 @@ +export * from './api'; export * from './integration'; export * from './route'; export * from './stage'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts index 7255607639468..83e200aadb007 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/integration.ts @@ -9,4 +9,4 @@ export interface IIntegration extends IResource { * @attribute */ readonly integrationId: string; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts index b608a7a34ad97..40b7832418633 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/stage.ts @@ -1,4 +1,6 @@ +import { Metric, MetricOptions } from '@aws-cdk/aws-cloudwatch'; import { IResource } from '@aws-cdk/core'; +import { IDomainName } from './domain-name'; /** * Represents a Stage. @@ -9,22 +11,107 @@ export interface IStage extends IResource { * @attribute */ readonly stageName: string; + + /** + * The URL to this stage. + */ + readonly url: string; + + /** + * Return the given named metric for this HTTP Api Gateway Stage + * + * @default - average over 5 minutes + */ + metric(metricName: string, props?: MetricOptions): Metric + + /** + * Metric for the number of client-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricClientError(props?: MetricOptions): Metric + + /** + * Metric for the number of server-side errors captured in a given period. + * + * @default - sum over 5 minutes + */ + metricServerError(props?: MetricOptions): Metric + + /** + * Metric for the amount of data processed in bytes. + * + * @default - sum over 5 minutes + */ + metricDataProcessed(props?: MetricOptions): Metric + + /** + * Metric for the total number API requests in a given period. + * + * @default - SampleCount over 5 minutes + */ + metricCount(props?: MetricOptions): Metric + + /** + * Metric for the time between when API Gateway relays a request to the backend + * and when it receives a response from the backend. + * + * @default - no statistic + */ + metricIntegrationLatency(props?: MetricOptions): Metric + + /** + * The time between when API Gateway receives a request from a client + * and when it returns a response to the client. + * The latency includes the integration latency and other API Gateway overhead. + * + * @default - no statistic + */ + metricLatency(props?: MetricOptions): Metric } /** - * Options required to create a new stage. - * Options that are common between HTTP and Websocket APIs. + * Options for DomainMapping */ -export interface CommonStageOptions { +export interface DomainMappingOptions { /** - * The name of the stage. See `StageName` class for more details. - * @default '$default' the default stage of the API. This stage will have the URL at the root of the API endpoint. + * The domain name for the mapping + * */ - readonly stageName?: string; + readonly domainName: IDomainName; + /** + * The API mapping key. Leave it undefined for the root path mapping. + * @default - empty key for the root path mapping + */ + readonly mappingKey?: string; +} + +/** + * Options required to create a new stage. + * Options that are common between HTTP and Websocket APIs. + */ +export interface StageOptions { /** * Whether updates to an API automatically trigger a new deployment. * @default false */ readonly autoDeploy?: boolean; + + /** + * The options for custom domain and api mapping + * + * @default - no custom domain and api mapping configuration + */ + readonly domainMapping?: DomainMappingOptions; +} + +/** + * The attributes used to import existing Stage + */ +export interface StageAttributes { + /** + * The name of the stage + */ + readonly stageName: string; } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts deleted file mode 100644 index ee9323240833d..0000000000000 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Resource } from '@aws-cdk/core'; -import { Construct } from 'constructs'; -import { CfnApiMapping, CfnApiMappingProps } from '../apigatewayv2.generated'; -import { IApiMapping, IDomainName } from '../common'; -import { IHttpApi } from '../http/api'; -import { IHttpStage } from './stage'; - -/** - * Properties used to create the HttpApiMapping resource - */ -export interface HttpApiMappingProps { - /** - * Api mapping key. The path where this stage should be mapped to on the domain - * @default - undefined for the root path mapping. - */ - readonly apiMappingKey?: string; - - /** - * The HttpApi to which this mapping is applied - */ - readonly api: IHttpApi; - - /** - * custom domain name of the mapping target - */ - readonly domainName: IDomainName; - - /** - * stage for the HttpApiMapping resource - * - * @default - the $default stage - */ - readonly stage?: IHttpStage; -} - -/** - * The attributes used to import existing HttpApiMapping - */ -export interface HttpApiMappingAttributes { - /** - * The API mapping ID - */ - readonly apiMappingId: string; -} - -/** - * Create a new API mapping for API Gateway HTTP API endpoint. - * @resource AWS::ApiGatewayV2::ApiMapping - */ -export class HttpApiMapping extends Resource implements IApiMapping { - /** - * import from API ID - */ - public static fromHttpApiMappingAttributes(scope: Construct, id: string, attrs: HttpApiMappingAttributes): IApiMapping { - class Import extends Resource implements IApiMapping { - public readonly apiMappingId = attrs.apiMappingId; - } - return new Import(scope, id); - } - /** - * ID of the API Mapping - */ - public readonly apiMappingId: string; - - /** - * API Mapping key - */ - public readonly mappingKey?: string; - - constructor(scope: Construct, id: string, props: HttpApiMappingProps) { - super(scope, id); - - if ((!props.stage?.stageName) && !props.api.defaultStage) { - throw new Error('stage is required if default stage is not available'); - } - - const paramRe = '^[a-zA-Z0-9]*[-_.+!,$]?[a-zA-Z0-9]*$'; - if (props.apiMappingKey && !new RegExp(paramRe).test(props.apiMappingKey)) { - throw new Error('An ApiMapping key may contain only letters, numbers and one of $-_.+!*\'(),'); - } - - if (props.apiMappingKey === '') { - throw new Error('empty string for api mapping key not allowed'); - } - - const apiMappingProps: CfnApiMappingProps = { - apiId: props.api.httpApiId, - domainName: props.domainName.name, - stage: props.stage?.stageName ?? props.api.defaultStage!.stageName, - apiMappingKey: props.apiMappingKey, - }; - - const resource = new CfnApiMapping(this, 'Resource', apiMappingProps); - - // ensure the dependency on the provided stage - if (props.stage) { - this.node.addDependency(props.stage); - } - - // if stage not specified, we ensure the default stage is ready before we create the api mapping - if (!props.stage?.stageName && props.api.defaultStage) { - this.node.addDependency(props.api.defaultStage!); - } - - this.apiMappingId = resource.ref; - this.mappingKey = props.apiMappingKey; - } - -} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts index 52e5f1fccbe07..b74c00e5824fb 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts @@ -1,9 +1,9 @@ -import * as crypto from 'crypto'; -import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; -import { Duration, IResource, Resource, Stack } from '@aws-cdk/core'; +import { Duration } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnApi, CfnApiProps } from '../apigatewayv2.generated'; -import { DefaultDomainMappingOptions } from '../http/stage'; +import { IApi } from '../common/api'; +import { ApiBase } from '../common/base'; +import { DomainMappingOptions, IStage } from '../common/stage'; import { IHttpRouteAuthorizer } from './authorizer'; import { IHttpRouteIntegration, HttpIntegration, HttpRouteIntegrationConfig } from './integration'; import { BatchHttpRouteOptions, HttpMethod, HttpRoute, HttpRouteKey } from './route'; @@ -13,76 +13,14 @@ import { VpcLink, VpcLinkProps } from './vpc-link'; /** * Represents an HTTP API */ -export interface IHttpApi extends IResource { +export interface IHttpApi extends IApi { /** * The identifier of this API Gateway HTTP API. * @attribute + * @deprecated - use apiId instead */ readonly httpApiId: string; - /** - * The default endpoint for an API - * @attribute - */ - readonly apiEndpoint: string; - - /** - * The default stage - */ - readonly defaultStage?: HttpStage; - - /** - * Return the given named metric for this HTTP Api Gateway - * - * @default - average over 5 minutes - */ - metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the number of client-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the number of server-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the amount of data processed in bytes. - * - * @default - sum over 5 minutes - */ - metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the total number API requests in a given period. - * - * @default - SampleCount over 5 minutes - */ - metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * Metric for the time between when API Gateway relays a request to the backend - * and when it receives a response from the backend. - * - * @default - no statistic - */ - metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - - /** - * The time between when API Gateway receives a request from a client - * and when it returns a response to the client. - * The latency includes the integration latency and other API Gateway overhead. - * - * @default - no statistic - */ - metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric; - /** * Add a new VpcLink */ @@ -135,7 +73,7 @@ export interface HttpApiProps { * * @default - no default domain mapping configured. meaningless if `createDefaultStage` is `false`. */ - readonly defaultDomainMapping?: DefaultDomainMappingOptions; + readonly defaultDomainMapping?: DomainMappingOptions; /** * Specifies whether clients can invoke your API using the default endpoint. @@ -218,45 +156,12 @@ export interface AddRoutesOptions extends BatchHttpRouteOptions { readonly authorizationScopes?: string[]; } -abstract class HttpApiBase extends Resource implements IHttpApi { // note that this is not exported +abstract class HttpApiBase extends ApiBase implements IHttpApi { // note that this is not exported + public abstract readonly apiId: string; public abstract readonly httpApiId: string; public abstract readonly apiEndpoint: string; private vpcLinks: Record = {}; - private httpIntegrations: Record = {}; - - public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return new cloudwatch.Metric({ - namespace: 'AWS/ApiGateway', - metricName, - dimensions: { ApiId: this.httpApiId }, - ...props, - }).attachTo(this); - } - - public metricClientError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('4XXError', { statistic: 'Sum', ...props }); - } - - public metricServerError(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('5XXError', { statistic: 'Sum', ...props }); - } - - public metricDataProcessed(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('DataProcessed', { statistic: 'Sum', ...props }); - } - - public metricCount(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('Count', { statistic: 'SampleCount', ...props }); - } - - public metricIntegrationLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('IntegrationLatency', props); - } - - public metricLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { - return this.metric('Latency', props); - } public addVpcLink(options: VpcLinkProps): VpcLink { const { vpcId } = options.vpc; @@ -275,11 +180,9 @@ abstract class HttpApiBase extends Resource implements IHttpApi { // note that t * @internal */ public _addIntegration(scope: Construct, config: HttpRouteIntegrationConfig): HttpIntegration { - const stringifiedConfig = JSON.stringify(Stack.of(scope).resolve(config)); - const configHash = crypto.createHash('md5').update(stringifiedConfig).digest('hex'); - - if (configHash in this.httpIntegrations) { - return this.httpIntegrations[configHash]; + const { configHash, integration: existingIntegration } = this._integrationCache.getIntegration(scope, config); + if (existingIntegration) { + return existingIntegration as HttpIntegration; } const integration = new HttpIntegration(scope, `HttpIntegration-${configHash}`, { @@ -291,7 +194,7 @@ abstract class HttpApiBase extends Resource implements IHttpApi { // note that t connectionType: config.connectionType, payloadFormatVersion: config.payloadFormatVersion, }); - this.httpIntegrations[configHash] = integration; + this._integrationCache.saveIntegration(scope, config, integration); return integration; } @@ -322,6 +225,7 @@ export class HttpApi extends HttpApiBase { */ public static fromHttpApiAttributes(scope: Construct, id: string, attrs: HttpApiAttributes): IHttpApi { class Import extends HttpApiBase { + public readonly apiId = attrs.httpApiId; public readonly httpApiId = attrs.httpApiId; private readonly _apiEndpoint = attrs.apiEndpoint; @@ -339,6 +243,7 @@ export class HttpApi extends HttpApiBase { * A human friendly name for this HTTP API. Note that this is different from `httpApiId`. */ public readonly httpApiName?: string; + public readonly apiId: string; public readonly httpApiId: string; /** @@ -347,9 +252,9 @@ export class HttpApi extends HttpApiBase { public readonly disableExecuteApiEndpoint?: boolean; /** - * default stage of the api resource + * The default stage of this API */ - public readonly defaultStage: HttpStage | undefined; + public readonly defaultStage: IStage | undefined; private readonly _apiEndpoint: string; @@ -392,6 +297,7 @@ export class HttpApi extends HttpApiBase { }; const resource = new CfnApi(this, 'Resource', apiProps); + this.apiId = resource.ref; this.httpApiId = resource.ref; this._apiEndpoint = resource.attrApiEndpoint; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts index efd60f9f24d7c..81ddfec695bc3 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts @@ -2,6 +2,5 @@ export * from './api'; export * from './route'; export * from './integration'; export * from './stage'; -export * from './api-mapping'; export * from './vpc-link'; export * from './authorizer'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts index fbe54345e25e3..d6a5f96320e3b 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts @@ -1,11 +1,10 @@ -import { Metric, MetricOptions } from '@aws-cdk/aws-cloudwatch'; -import { Resource, Stack } from '@aws-cdk/core'; +import { Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnStage } from '../apigatewayv2.generated'; -import { CommonStageOptions, IDomainName, IStage } from '../common'; +import { StageOptions, IStage, StageAttributes } from '../common'; +import { IApi } from '../common/api'; +import { StageBase } from '../common/base'; import { IHttpApi } from './api'; -import { HttpApiMapping } from './api-mapping'; - const DEFAULT_STAGE_NAME = '$default'; @@ -13,18 +12,21 @@ const DEFAULT_STAGE_NAME = '$default'; * Represents the HttpStage */ export interface IHttpStage extends IStage { + /** + * The API this stage is associated to. + */ + readonly api: IHttpApi; } /** - * Options to create a new stage for an HTTP API. + * The options to create a new Stage for an HTTP API */ -export interface HttpStageOptions extends CommonStageOptions { +export interface HttpStageOptions extends StageOptions { /** - * The options for custom domain and api mapping - * - * @default - no custom domain and api mapping configuration + * The name of the stage. See `StageName` class for more details. + * @default '$default' the default stage of the API. This stage will have the URL at the root of the API endpoint. */ - readonly domainMapping?: DomainMappingOptions; + readonly stageName?: string; } /** @@ -38,51 +40,39 @@ export interface HttpStageProps extends HttpStageOptions { } /** - * Options for defaultDomainMapping + * The attributes used to import existing HttpStage */ -export interface DefaultDomainMappingOptions { - /** - * The domain name for the mapping - * - */ - readonly domainName: IDomainName; - +export interface HttpStageAttributes extends StageAttributes { /** - * The API mapping key. Leave it undefined for the root path mapping. - * @default - empty key for the root path mapping + * The API to which this stage is associated */ - readonly mappingKey?: string; -} - -/** - * Options for DomainMapping - */ -export interface DomainMappingOptions extends DefaultDomainMappingOptions { - /** - * The API Stage - * - * @default - the $default stage - */ - readonly stage?: IStage; + readonly api: IHttpApi; } /** * Represents a stage where an instance of the API is deployed. * @resource AWS::ApiGatewayV2::Stage */ -export class HttpStage extends Resource implements IStage { +export class HttpStage extends StageBase implements IHttpStage { /** * Import an existing stage into this CDK app. */ - public static fromStageName(scope: Construct, id: string, stageName: string): IStage { - class Import extends Resource implements IStage { - public readonly stageName = stageName; + public static fromHttpStageAttributes(scope: Construct, id: string, attrs: HttpStageAttributes): IHttpStage { + class Import extends StageBase implements IHttpStage { + protected readonly baseApi = attrs.api; + public readonly stageName = attrs.stageName; + public readonly api = attrs.api; + + get url(): string { + throw new Error('url is not available for imported stages.'); + } } return new Import(scope, id); } + protected readonly baseApi: IApi; public readonly stageName: string; - private httpApi: IHttpApi; + public readonly api: IHttpApi; constructor(scope: Construct, id: string, props: HttpStageProps) { super(scope, id, { @@ -90,25 +80,18 @@ export class HttpStage extends Resource implements IStage { }); new CfnStage(this, 'Resource', { - apiId: props.httpApi.httpApiId, + apiId: props.httpApi.apiId, stageName: this.physicalName, autoDeploy: props.autoDeploy, }); this.stageName = this.physicalName; - this.httpApi = props.httpApi; + this.baseApi = props.httpApi; + this.api = props.httpApi; if (props.domainMapping) { - new HttpApiMapping(this, `${props.domainMapping.domainName}${props.domainMapping.mappingKey}`, { - api: props.httpApi, - domainName: props.domainMapping.domainName, - stage: this, - apiMappingKey: props.domainMapping.mappingKey, - }); - // ensure the dependency - this.node.addDependency(props.domainMapping.domainName); + this._addDomainMapping(props.domainMapping); } - } /** @@ -117,75 +100,6 @@ export class HttpStage extends Resource implements IStage { public get url(): string { const s = Stack.of(this); const urlPath = this.stageName === DEFAULT_STAGE_NAME ? '' : this.stageName; - return `https://${this.httpApi.httpApiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; - } - - /** - * Return the given named metric for this HTTP Api Gateway Stage - * - * @default - average over 5 minutes - */ - public metric(metricName: string, props?: MetricOptions): Metric { - var api = this.httpApi; - return api.metric(metricName, props).with({ - dimensions: { ApiId: this.httpApi.httpApiId, Stage: this.stageName }, - }).attachTo(this); - } - - /** - * Metric for the number of client-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - public metricClientError(props?: MetricOptions): Metric { - return this.metric('4XXError', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the number of server-side errors captured in a given period. - * - * @default - sum over 5 minutes - */ - public metricServerError(props?: MetricOptions): Metric { - return this.metric('5XXError', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the amount of data processed in bytes. - * - * @default - sum over 5 minutes - */ - public metricDataProcessed(props?: MetricOptions): Metric { - return this.metric('DataProcessed', { statistic: 'Sum', ...props }); - } - - /** - * Metric for the total number API requests in a given period. - * - * @default - SampleCount over 5 minutes - */ - public metricCount(props?: MetricOptions): Metric { - return this.metric('Count', { statistic: 'SampleCount', ...props }); - } - - /** - * Metric for the time between when API Gateway relays a request to the backend - * and when it receives a response from the backend. - * - * @default - no statistic - */ - public metricIntegrationLatency(props?: MetricOptions): Metric { - return this.metric('IntegrationLatency', props); - } - - /** - * The time between when API Gateway receives a request from a client - * and when it returns a response to the client. - * The latency includes the integration latency and other API Gateway overhead. - * - * @default - no statistic - */ - public metricLatency(props?: MetricOptions): Metric { - return this.metric('Latency', props); + return `https://${this.api.apiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; } } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts index 31ea86b4a91c2..12dd8113f8b4c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/index.ts @@ -1,3 +1,4 @@ export * from './apigatewayv2.generated'; export * from './common'; -export * from './http'; \ No newline at end of file +export * from './http'; +export * from './websocket'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts new file mode 100644 index 0000000000000..2401d28e20d2d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/private/integration-cache.ts @@ -0,0 +1,29 @@ +import * as crypto from 'crypto'; +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IIntegration } from '../common/integration'; +import { HttpRouteIntegrationConfig } from '../http'; +import { WebSocketRouteIntegrationConfig } from '../websocket'; + +type IntegrationConfig = HttpRouteIntegrationConfig | WebSocketRouteIntegrationConfig; + +export class IntegrationCache { + private integrations: Record = {}; + + getIntegration(scope: Construct, config: IntegrationConfig) { + const configHash = this.integrationConfigHash(scope, config); + const integration = this.integrations[configHash]; + return { configHash, integration }; + } + + saveIntegration(scope: Construct, config: IntegrationConfig, integration: IIntegration) { + const configHash = this.integrationConfigHash(scope, config); + this.integrations[configHash] = integration; + } + + private integrationConfigHash(scope: Construct, config: IntegrationConfig): string { + const stringifiedConfig = JSON.stringify(Stack.of(scope).resolve(config)); + const configHash = crypto.createHash('md5').update(stringifiedConfig).digest('hex'); + return configHash; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts new file mode 100644 index 0000000000000..f2f2653c94ee6 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts @@ -0,0 +1,130 @@ +import { Construct } from 'constructs'; +import { CfnApi } from '../apigatewayv2.generated'; +import { IApi } from '../common/api'; +import { ApiBase } from '../common/base'; +import { WebSocketRouteIntegrationConfig, WebSocketIntegration } from './integration'; +import { WebSocketRoute, WebSocketRouteOptions } from './route'; + +/** + * Represents a WebSocket API + */ +export interface IWebSocketApi extends IApi { + /** + * Add a websocket integration + * @internal + */ + _addIntegration(scope: Construct, config: WebSocketRouteIntegrationConfig): WebSocketIntegration +} + +/** + * Props for WebSocket API + */ +export interface WebSocketApiProps { + /** + * Name for the WebSocket API resoruce + * @default - id of the WebSocketApi construct. + */ + readonly apiName?: string; + + /** + * The description of the API. + * @default - none + */ + readonly description?: string; + + /** + * The route selection expression for the API + * @default '$request.body.action' + */ + readonly routeSelectionExpression?: string; + + /** + * Options to configure a '$connect' route + * + * @default - no '$connect' route configured + */ + readonly connectRouteOptions?: WebSocketRouteOptions; + + /** + * Options to configure a '$disconnect' route + * + * @default - no '$disconnect' route configured + */ + readonly disconnectRouteOptions?: WebSocketRouteOptions; + + /** + * Options to configure a '$default' route + * + * @default - no '$default' route configured + */ + readonly defaultRouteOptions?: WebSocketRouteOptions; +} + +/** + * Create a new API Gateway WebSocket API endpoint. + * @resource AWS::ApiGatewayV2::Api + */ +export class WebSocketApi extends ApiBase implements IWebSocketApi { + public readonly apiId: string; + public readonly apiEndpoint: string; + + /** + * A human friendly name for this WebSocket API. Note that this is different from `webSocketApiId`. + */ + public readonly webSocketApiName?: string; + + constructor(scope: Construct, id: string, props?: WebSocketApiProps) { + super(scope, id); + + this.webSocketApiName = props?.apiName ?? id; + + const resource = new CfnApi(this, 'Resource', { + name: this.webSocketApiName, + protocolType: 'WEBSOCKET', + description: props?.description, + routeSelectionExpression: props?.routeSelectionExpression ?? '$request.body.action', + }); + this.apiId = resource.ref; + this.apiEndpoint = resource.attrApiEndpoint; + + if (props?.connectRouteOptions) { + this.addRoute('$connect', props.connectRouteOptions); + } + if (props?.disconnectRouteOptions) { + this.addRoute('$disconnect', props.disconnectRouteOptions); + } + if (props?.defaultRouteOptions) { + this.addRoute('$default', props.defaultRouteOptions); + } + } + + /** + * @internal + */ + public _addIntegration(scope: Construct, config: WebSocketRouteIntegrationConfig): WebSocketIntegration { + const { configHash, integration: existingIntegration } = this._integrationCache.getIntegration(scope, config); + if (existingIntegration) { + return existingIntegration as WebSocketIntegration; + } + + const integration = new WebSocketIntegration(scope, `WebSocketIntegration-${configHash}`, { + webSocketApi: this, + integrationType: config.type, + integrationUri: config.uri, + }); + this._integrationCache.saveIntegration(scope, config, integration); + + return integration; + } + + /** + * Add a new route + */ + public addRoute(routeKey: string, options: WebSocketRouteOptions) { + return new WebSocketRoute(this, `${routeKey}-Route`, { + webSocketApi: this, + routeKey, + ...options, + }); + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts new file mode 100644 index 0000000000000..b0ce6a8a91419 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './route'; +export * from './stage'; +export * from './integration'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts new file mode 100644 index 0000000000000..e75bd00b63d95 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/integration.ts @@ -0,0 +1,110 @@ +import { Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnIntegration } from '../apigatewayv2.generated'; +import { IIntegration } from '../common'; +import { IWebSocketApi } from './api'; +import { IWebSocketRoute } from './route'; + +// v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. +// eslint-disable-next-line +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Represents an Integration for an WebSocket API. + */ +export interface IWebSocketIntegration extends IIntegration { + /** The WebSocket API associated with this integration */ + readonly webSocketApi: IWebSocketApi; +} + +/** + * WebSocket Integration Types + */ +export enum WebSocketIntegrationType { + /** + * AWS Proxy Integration Type + */ + AWS_PROXY = 'AWS_PROXY' +} + +/** + * The integration properties + */ +export interface WebSocketIntegrationProps { + /** + * The WebSocket API to which this integration should be bound. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * Integration type + */ + readonly integrationType: WebSocketIntegrationType; + + /** + * Integration URI. + */ + readonly integrationUri: string; +} + +/** + * The integration for an API route. + * @resource AWS::ApiGatewayV2::Integration + */ +export class WebSocketIntegration extends Resource implements IWebSocketIntegration { + public readonly integrationId: string; + public readonly webSocketApi: IWebSocketApi; + + constructor(scope: Construct, id: string, props: WebSocketIntegrationProps) { + super(scope, id); + const integ = new CfnIntegration(this, 'Resource', { + apiId: props.webSocketApi.apiId, + integrationType: props.integrationType, + integrationUri: props.integrationUri, + }); + this.integrationId = integ.ref; + this.webSocketApi = props.webSocketApi; + } +} + +/** + * Options to the WebSocketRouteIntegration during its bind operation. + */ +export interface WebSocketRouteIntegrationBindOptions { + /** + * The route to which this is being bound. + */ + readonly route: IWebSocketRoute; + + /** + * The current scope in which the bind is occurring. + * If the `WebSocketRouteIntegration` being bound creates additional constructs, + * this will be used as their parent scope. + */ + readonly scope: CoreConstruct; +} + +/** + * The interface that various route integration classes will inherit. + */ +export interface IWebSocketRouteIntegration { + /** + * Bind this integration to the route. + */ + bind(options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig; +} + +/** + * Config returned back as a result of the bind. + */ +export interface WebSocketRouteIntegrationConfig { + /** + * Integration type. + */ + readonly type: WebSocketIntegrationType; + + /** + * Integration URI + */ + readonly uri: string; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts new file mode 100644 index 0000000000000..0588889a603bc --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts @@ -0,0 +1,84 @@ +import { Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnRoute } from '../apigatewayv2.generated'; +import { IRoute } from '../common'; +import { IWebSocketApi } from './api'; +import { IWebSocketRouteIntegration } from './integration'; + +/** + * Represents a Route for an WebSocket API. + */ +export interface IWebSocketRoute extends IRoute { + /** + * The WebSocket API associated with this route. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The key to this route. + * @attribute + */ + readonly routeKey: string; +} + +/** + * Options used to add route to the API + */ +export interface WebSocketRouteOptions { + /** + * The integration to be configured on this route. + */ + readonly integration: IWebSocketRouteIntegration; +} + + +/** + * Properties to initialize a new Route + */ +export interface WebSocketRouteProps extends WebSocketRouteOptions { + /** + * the API the route is associated with + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The key to this route. + */ + readonly routeKey: string; +} + +/** + * Route class that creates the Route for API Gateway WebSocket API + * @resource AWS::ApiGatewayV2::Route + */ +export class WebSocketRoute extends Resource implements IWebSocketRoute { + public readonly routeId: string; + public readonly webSocketApi: IWebSocketApi; + public readonly routeKey: string; + + /** + * Integration response ID + */ + public readonly integrationResponseId?: string; + + constructor(scope: Construct, id: string, props: WebSocketRouteProps) { + super(scope, id); + + this.webSocketApi = props.webSocketApi; + this.routeKey = props.routeKey; + + const config = props.integration.bind({ + route: this, + scope: this, + }); + + const integration = props.webSocketApi._addIntegration(this, config); + + const route = new CfnRoute(this, 'Resource', { + apiId: props.webSocketApi.apiId, + routeKey: props.routeKey, + target: `integrations/${integration.integrationId}`, + }); + this.routeId = route.ref; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts new file mode 100644 index 0000000000000..a50353a79ca2d --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/stage.ts @@ -0,0 +1,96 @@ +import { Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnStage } from '../apigatewayv2.generated'; +import { StageOptions, IApi, IStage, StageAttributes } from '../common'; +import { StageBase } from '../common/base'; +import { IWebSocketApi } from './api'; + +/** + * Represents the WebSocketStage + */ +export interface IWebSocketStage extends IStage { + /** + * The API this stage is associated to. + */ + readonly api: IWebSocketApi; +} + +/** + * Properties to initialize an instance of `WebSocketStage`. + */ +export interface WebSocketStageProps extends StageOptions { + /** + * The WebSocket API to which this stage is associated. + */ + readonly webSocketApi: IWebSocketApi; + + /** + * The name of the stage. + */ + readonly stageName: string; +} + +/** + * The attributes used to import existing WebSocketStage + */ +export interface WebSocketStageAttributes extends StageAttributes { + /** + * The API to which this stage is associated + */ + readonly api: IWebSocketApi; +} + +/** + * Represents a stage where an instance of the API is deployed. + * @resource AWS::ApiGatewayV2::Stage + */ +export class WebSocketStage extends StageBase implements IWebSocketStage { + /** + * Import an existing stage into this CDK app. + */ + public static fromWebSocketStageAttributes(scope: Construct, id: string, attrs: WebSocketStageAttributes): IWebSocketStage { + class Import extends StageBase implements IWebSocketStage { + public readonly baseApi = attrs.api; + public readonly stageName = attrs.stageName; + public readonly api = attrs.api; + + get url(): string { + throw new Error('url is not available for imported stages.'); + } + } + return new Import(scope, id); + } + + protected readonly baseApi: IApi; + public readonly stageName: string; + public readonly api: IWebSocketApi; + + constructor(scope: Construct, id: string, props: WebSocketStageProps) { + super(scope, id, { + physicalName: props.stageName, + }); + + this.baseApi = props.webSocketApi; + this.api = props.webSocketApi; + this.stageName = this.physicalName; + + new CfnStage(this, 'Resource', { + apiId: props.webSocketApi.apiId, + stageName: this.physicalName, + autoDeploy: props.autoDeploy, + }); + + if (props.domainMapping) { + this._addDomainMapping(props.domainMapping); + } + } + + /** + * The URL to this stage. + */ + public get url(): string { + const s = Stack.of(this); + const urlPath = this.stageName; + return `wss://${this.api.apiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/package.json b/packages/@aws-cdk/aws-apigatewayv2/package.json index 19abb0ca10b3f..b1dd874b85f9e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/package.json +++ b/packages/@aws-cdk/aws-apigatewayv2/package.json @@ -103,13 +103,16 @@ }, "awslint": { "exclude": [ + "props-physical-name:@aws-cdk/aws-apigatewayv2.ApiMappingProps", "from-method:@aws-cdk/aws-apigatewayv2.HttpIntegration", "from-method:@aws-cdk/aws-apigatewayv2.HttpRoute", - "from-method:@aws-cdk/aws-apigatewayv2.HttpStage", - "props-physical-name-type:@aws-cdk/aws-apigatewayv2.HttpStageProps.stageName", - "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpApiMappingProps", "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpIntegrationProps", - "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpRouteProps" + "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpRouteProps", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketApi", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketIntegration", + "from-method:@aws-cdk/aws-apigatewayv2.WebSocketRoute", + "props-physical-name:@aws-cdk/aws-apigatewayv2.WebSocketIntegrationProps", + "props-physical-name:@aws-cdk/aws-apigatewayv2.WebSocketRouteProps" ] }, "stability": "experimental", @@ -121,7 +124,7 @@ }, { "name": "Higher level constructs for Websocket APIs", - "stability": "Not Implemented" + "stability": "Experimental" } ], "awscdkio": { diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts similarity index 76% rename from packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts rename to packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts index fe113727c3f50..b917f19513a57 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/common/api-mapping.test.ts @@ -1,7 +1,7 @@ import '@aws-cdk/assert/jest'; import { Certificate } from '@aws-cdk/aws-certificatemanager'; import { Stack } from '@aws-cdk/core'; -import { DomainName, HttpApi, HttpApiMapping } from '../../lib'; +import { DomainName, HttpApi, ApiMapping, WebSocketApi } from '../../lib'; const domainName = 'example.com'; const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; @@ -17,7 +17,7 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, }); @@ -47,7 +47,7 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, stage: beta, @@ -75,7 +75,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '', @@ -94,7 +94,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '/', @@ -113,7 +113,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '/foo', @@ -132,7 +132,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo/bar', @@ -151,7 +151,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo/', @@ -170,7 +170,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: '^foo', @@ -189,7 +189,7 @@ describe('ApiMapping', () => { }); expect(() => { - new HttpApiMapping(stack, 'Mapping', { + new ApiMapping(stack, 'Mapping', { api, domainName: dn, apiMappingKey: 'foo.*$', @@ -207,15 +207,53 @@ describe('ApiMapping', () => { certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), }); - const mapping = new HttpApiMapping(stack, 'Mapping', { + const mapping = new ApiMapping(stack, 'Mapping', { api, domainName: dn, }); - const imported = HttpApiMapping.fromHttpApiMappingAttributes(stack, 'ImportedMapping', { + const imported = ApiMapping.fromApiMappingAttributes(stack, 'ImportedMapping', { apiMappingId: mapping.apiMappingId, } ); expect(imported.apiMappingId).toEqual(mapping.apiMappingId); }); + + test('stage validation - throws if defaultStage not available for HttpApi', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'Api', { + createDefaultStage: false, + }); + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + expect(() => { + new ApiMapping(stack, 'Mapping', { + api, + domainName: dn, + }); + }).toThrow(/stage is required if default stage is not available/); + }); + + test('stage validation - throws if stage not provided for WebSocketApi', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api'); + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + expect(() => { + new ApiMapping(stack, 'Mapping', { + api, + domainName: dn, + }); + }).toThrow(/stage is required for WebSocket API/); + }); }); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts index 01252be7d84f1..a8c5f418f7782 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts @@ -19,7 +19,7 @@ describe('HttpApi', () => { }); expect(stack).toHaveResource('AWS::ApiGatewayV2::Stage', { - ApiId: stack.resolve(api.httpApiId), + ApiId: stack.resolve(api.apiId), StageName: '$default', AutoDeploy: true, }); @@ -34,7 +34,7 @@ describe('HttpApi', () => { const stack = new Stack(); const imported = HttpApi.fromHttpApiAttributes(stack, 'imported', { httpApiId: 'http-1234', apiEndpoint: 'api-endpoint' }); - expect(imported.httpApiId).toEqual('http-1234'); + expect(imported.apiId).toEqual('http-1234'); expect(imported.apiEndpoint).toEqual('api-endpoint'); }); @@ -55,12 +55,12 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: '$default', }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Integration', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), }); }); @@ -75,12 +75,12 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'GET /pets', }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'PATCH /pets', }); }); @@ -95,7 +95,7 @@ describe('HttpApi', () => { }); expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::Route', { - ApiId: stack.resolve(httpApi.httpApiId), + ApiId: stack.resolve(httpApi.apiId), RouteKey: 'ANY /pets', }); }); @@ -149,7 +149,7 @@ describe('HttpApi', () => { }); const metricName = '4xxError'; const statistic = 'Sum'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const countMetric = api.metric(metricName, { statistic }); @@ -168,7 +168,7 @@ describe('HttpApi', () => { createDefaultStage: false, }); const color = '#00ff00'; - const apiId = api.httpApiId; + const apiId = api.apiId; // WHEN const metrics = new Array(); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts index 6c4359b5439c9..ec1e28542d598 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts @@ -31,7 +31,10 @@ describe('HttpStage', () => { httpApi: api, }); - const imported = HttpStage.fromStageName(stack, 'Import', stage.stageName ); + const imported = HttpStage.fromHttpStageAttributes(stack, 'Import', { + stageName: stage.stageName, + api, + }); expect(imported.stageName).toEqual(stage.stageName); }); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts new file mode 100644 index 0000000000000..fcc65d4e18207 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts @@ -0,0 +1,92 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { + IWebSocketRouteIntegration, WebSocketApi, WebSocketIntegrationType, + WebSocketRouteIntegrationBindOptions, WebSocketRouteIntegrationConfig, +} from '../../lib'; + +describe('WebSocketApi', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new WebSocketApi(stack, 'api'); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Api', { + Name: 'api', + ProtocolType: 'WEBSOCKET', + }); + + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Stage'); + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Route'); + expect(stack).not.toHaveResource('AWS::ApiGatewayV2::Integration'); + }); + + test('addRoute: adds a route with passed key', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api'); + + // WHEN + api.addRoute('myroute', { integration: new DummyIntegration() }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: 'myroute', + }); + }); + + test('connectRouteOptions: adds a $connect route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + connectRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$connect', + }); + }); + + test('disconnectRouteOptions: adds a $disconnect route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + disconnectRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$disconnect', + }); + }); + + test('defaultRouteOptions: adds a $default route', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'api', { + defaultRouteOptions: { integration: new DummyIntegration() }, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(api.apiId), + RouteKey: '$default', + }); + }); +}); + +class DummyIntegration implements IWebSocketRouteIntegration { + bind(_options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts new file mode 100644 index 0000000000000..04e8e5fc7efac --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts @@ -0,0 +1,54 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { + IWebSocketRouteIntegration, WebSocketApi, WebSocketIntegrationType, + WebSocketRoute, WebSocketRouteIntegrationBindOptions, WebSocketRouteIntegrationConfig, +} from '../../lib'; + +describe('WebSocketRoute', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const webSocketApi = new WebSocketApi(stack, 'Api'); + + // WHEN + new WebSocketRoute(stack, 'Route', { + webSocketApi, + integration: new DummyIntegration(), + routeKey: 'message', + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(webSocketApi.apiId), + RouteKey: 'message', + Target: { + 'Fn::Join': [ + '', + [ + 'integrations/', + { + Ref: 'RouteWebSocketIntegrationb7742333c7ab20d7b2b178df59bb17f20338431E', + }, + ], + ], + }, + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + ApiId: stack.resolve(webSocketApi.apiId), + IntegrationType: 'AWS_PROXY', + IntegrationUri: 'some-uri', + }); + }); +}); + + +class DummyIntegration implements IWebSocketRouteIntegration { + bind(_options: WebSocketRouteIntegrationBindOptions): WebSocketRouteIntegrationConfig { + return { + type: WebSocketIntegrationType.AWS_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts new file mode 100644 index 0000000000000..5ebdf0c61a980 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/stage.test.ts @@ -0,0 +1,44 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { WebSocketApi, WebSocketStage } from '../../lib'; + +describe('WebSocketStage', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'Api'); + + // WHEN + const defaultStage = new WebSocketStage(stack, 'Stage', { + webSocketApi: api, + stageName: 'dev', + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Stage', { + ApiId: stack.resolve(api.apiId), + StageName: 'dev', + }); + expect(defaultStage.url.endsWith('/dev')).toBe(true); + }); + + test('import', () => { + // GIVEN + const stack = new Stack(); + const api = new WebSocketApi(stack, 'Api'); + + // WHEN + const stage = new WebSocketStage(stack, 'Stage', { + webSocketApi: api, + stageName: 'dev', + }); + + const imported = WebSocketStage.fromWebSocketStageAttributes(stack, 'Import', { + stageName: stage.stageName, + api, + }); + + // THEN + expect(imported.stageName).toEqual(stage.stageName); + }); +});