WebSocket APIs in API gateway use a separate model from the REST APIs.
Having a set of models that provide the functionalities of API Gateway V2 in a more integrated way would simplify deployment of WebSocket APIs.
I have the base of it (used for a demo), will tidy up, and get a PR submitted
Looking into the details of my classes, ApiGatewayV2 has a lot of overlap with ApiGateway (v1), but very little compatibility. My recommendation would be to split the modules and have an apigateway v2 module that can evolve.
Any update for this?
I am also looking for creating a WebSocket API with cdk.
Not find in any doc the option to do this.
@julienlepine you have already a working code?? can you refer me to it?? thanks!
Hey all. I am traveling at the moment and I have no access to my computer. I should have something later this week, probably not a finalized one but a basis for getting something working.
Julien
Le 26 août 2019 à 17:37, BrianG13 notifications@github.com a écrit :
I am also looking for creating a WebSocket API with cdk.
Not find in any doc the option to do this.
@julienlepine you have already a working code?? can you refer me to it?? thanks!—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or mute the thread.
Hey, I continue trying to do something work also.
I am still having some troubles, I dont know why, but the websocket that I am creating and integrate with the lambda, does not appear in the AWS lambda website, so every connection try to webSocket return "internal server error".
But when I look at AWS apiGateWay website, everything looks good.
Another problem is that, I can't create&deploy at the same "cdk deploy hello-brian" command.
I have to deploy first without creating a stage+deployment, the routes are created.
and only on second time that I run "cdk deploy hello-brian" command api is deploy.. how this can be fixed??
Here is my python code:
# Create Lambda Function
my_lambda = _lambda.Function(self,id='LambdaHandler',
runtime=_lambda.Runtime.PYTHON_3_7,
code=_lambda.Code.asset('lambda'),
handler='hello.handler')
# Create WebSocket API
ws_api = apigw.CfnApiV2(self, id='WEB_SOCKET_FOR_LAMBDA_ID',
name='WebSocketForLambda',
protocol_type="WEBSOCKET",
route_selection_expression="$request.body.message")
# Create incoming Integration+Route
lambda_uri = f'arn:aws:apigateway:{self.region}:lambda:path/2015-03-31/functions/{my_lambda.function_arn}/invocations'
integration = apigw.CfnIntegrationV2(self,
id="WebSocketIntegration_id",
api_id=ws_api.ref,
integration_type="AWS_PROXY",
credentials_arn="arn:aws:iam::786303587674:user/cdk-workshop",
integration_uri=lambda_uri)
route = apigw.CfnRouteV2(self,
id="myWebSocketRoute_id",
api_id=ws_api.ref,
route_key="message",
authorization_type="NONE",
target="integrations/" + integration.ref,
route_response_selection_expression="$default")
integration_response = apigw.CfnIntegrationResponseV2(self,
id="WebSocketIntegrationResponse_id",
api_id=ws_api.ref,
integration_response_key="$default",
integration_id=integration.ref)
route_response = apigw.CfnRouteResponseV2(self,
id="WebSocketIntegrationResponseRoute_id",
api_id=ws_api.ref,
route_id=route.ref,
route_response_key="$default")
deployment = apigw.CfnDeploymentV2(self,
id="WebSocketDeployment",
api_id=ws_api.ref)
stage = apigw.CfnStageV2(self,
id="WebSocketStage",
api_id=ws_api.ref,
deployment_id=deployment.ref,
stage_name='beta')
Do you find any step here that it’s wrong? Or something missing??
@julienlepine any news?
Here is the code for my class (my computer is failing me, sorry for the delay).
import cdk = require('@aws-cdk/cdk');
import iam = require('@aws-cdk/aws-iam');
import apigw = require('@aws-cdk/aws-apigateway');
import lambda = require('@aws-cdk/aws-lambda');
import { isString } from 'util';
import crypto = require('crypto');
import { Stack } from '@aws-cdk/cdk';
/**
* Available protocols for ApiGateway V2 APIs (currently only 'WEBSOCKET' is supported)
*/
export enum ProtocolType {
/**
* WebSocket API
*/
WEBSOCKET = "WEBSOCKET"
}
/**
* The type of the network connection to the integration endpoint. Currently the only valid value is INTERNET, for connections through the public routable internet.
*/
export enum ConnectionType {
/**
* Internet connectivity through the public routable internet
*/
INTERNET = "INTERNET"
}
/**
* Specifies how to handle response payload content type conversions. Supported values are CONVERT_TO_BINARY and CONVERT_TO_TEXT.
*
* If this property is not defined, the response payload will be passed through from the integration response to the route response or method response without modification.
*/
export enum ContentHandlingStrategy {
/**
* Converts a response payload from a Base64-encoded string to the corresponding binary blob
*/
CONVERT_TO_BINARY = "CONVERT_TO_BINARY",
/**
* Converts a response payload from a binary blob to a Base64-encoded string
*/
CONVERT_TO_TEXT = "CONVERT_TO_TEXT"
}
/**
* The integration type of an integration.
*/
export enum IntegrationType {
/**
* Integration of the route or method request with an AWS service action, including the Lambda function-invoking action. With the Lambda function-invoking action, this is referred to as the Lambda custom integration. With any other AWS service action, this is known as AWS integration.
*/
AWS = "AWS",
/**
* Integration of the route or method request with the Lambda function-invoking action with the client request passed through as-is. This integration is also referred to as Lambda proxy integration.
*/
AWS_PROXY = "AWS_PROXY",
/**
* Integration of the route or method request with an HTTP endpoint. This integration is also referred to as HTTP custom integration.
*/
HTTP = "HTTP",
/**
* Integration of the route or method request with an HTTP endpoint, with the client request passed through as-is. This is also referred to as HTTP proxy integration.
*/
HTTP_PROXY = "HTTP_PROXY",
/**
* Integration of the route or method request with API Gateway as a "loopback" endpoint without invoking any backend.
*/
MOCK = "MOCK"
}
/**
* Specifies the integration's HTTP method type (only GET is supported for WebSocket)
*/
export enum IntegrationMethod {
/**
* GET HTTP Method
*/
GET = "GET"
}
/**
* Defines a set of common route keys known to the system
*/
export enum KnownRouteKey {
/**
* Default route, when no other pattern matches
*/
DEFAULT = "$default",
/**
* This route is a reserved route, used when a client establishes a connection to the WebSocket API
*/
CONNECT = "$connect",
/**
* This route is a reserved route, used when a client disconnects from the WebSocket API
*/
DISCONNECT = "$disconnect"
}
/**
* Defines a set of common response patterns known to the system
*/
export enum KnownResponseKey {
/**
* Default response, when no other pattern matches
*/
DEFAULT = "$default"
}
/**
* Defines a set of common template patterns known to the system
*/
export enum KnownTemplateKey {
/**
* Default template, when no other pattern matches
*/
DEFAULT = "$default"
}
/**
* Defines a set of common model patterns known to the system
*/
export enum KnownModelKey {
/**
* Default model, when no other pattern matches
*/
DEFAULT = "$default"
}
/**
* Defines a set of common content types for APIs
*/
export enum KnownContentTypes {
/**
* JSON request or response (default)
*/
JSON = "application/json",
/**
* XML request or response
*/
XML = "application/xml",
/**
* Pnain text request or response
*/
TEXT = "text/plain",
/**
* URL encoded web form
*/
FORM_URL_ENCODED = "application/x-www-form-urlencoded",
/**
* Data from a web form
*/
FORM_DATA = "multipart/form-data"
}
/**
* Specifies the pass-through behavior for incoming requests based on the
* Content-Type header in the request, and the available mapping templates
* specified as the requestTemplates property on the Integration resource.
*/
export enum PassthroughBehavior {
/**
* Passes the request body for unmapped content types through to the
* integration backend without transformation
*/
WHEN_NO_MATCH = "WHEN_NO_MATCH",
/**
* Allows pass-through when the integration has no content types mapped
* to templates. However, if there is at least one content type defined,
* unmapped content types will be rejected with an HTTP 415 Unsupported Media Type response
*/
WHEN_NO_TEMPLATES = "WHEN_NO_TEMPLATES",
/**
* Rejects unmapped content types with an HTTP 415 Unsupported Media Type response
*/
NEVER = "NEVER"
}
/**
* Specifies the logging level for this route. This property affects the log entries pushed to Amazon CloudWatch Logs.
*/
export enum LoggingLevel {
/**
* Displays all log information
*/
INFO = "INFO",
/**
* Only displays errors
*/
ERROR = "ERROR",
/**
* Logging is turned off
*/
OFF = "OFF"
}
/**
* Settings for logging access in a stage.
*/
export interface AccessLogSettings {
/**
* The ARN of the CloudWatch Logs log group to receive access logs.
*
* @default None
*/
readonly destinationArn?: string;
/**
* A single line format of the access logs of data, as specified by selected $context variables.
* The format must include at least $context.requestId.
*
* @default None
*/
readonly format?: string;
}
/**
* The schema for the model. For application/json models, this should be JSON schema draft 4 model.
*/
export interface SchemaDefinition {
readonly title: string;
[propName: string]: any;
}
export interface RouteSettings {
readonly dataTraceEnabled?: boolean;
readonly detailedMetricsEnabled?: boolean;
readonly loggingLevel?: LoggingLevel;
readonly throttlingBurstLimit?: number;
readonly throttlingRateLimit?: number;
}
export interface StageProps {
readonly accessLogSettings?: AccessLogSettings;
readonly clientCertificateId?: string;
readonly defaultRouteSettings?: RouteSettings;
/**
* Route settings for the stage.
*
* @default None
*/
readonly routeSettings?: { [key: string]: RouteSettings };
/**
* The description for the API stage.
*
* @default None
*/
readonly description?: string;
/**
* A map that defines the stage variables for a Stage.
* Variable names can have alphanumeric and underscore
* characters, and the values must match [A-Za-z0-9-._~:/?#&=,]+.
*
* @default None
*/
readonly stageVariables?: { [key: string]: RouteSettings };
}
export interface IntegrationProps {
readonly connectionType?: ConnectionType;
readonly contentHandlingStrategy?: ContentHandlingStrategy;
readonly credentialsArn?: string;
readonly proxy?: boolean;
readonly description?: string;
readonly passthroughBehavior?: PassthroughBehavior;
readonly requestParameters?: { [key: string]: string };
readonly requestTemplates?: { [key: string]: string };
readonly templateSelectionExpression?: KnownTemplateKey | string;
readonly timeoutInMillis?: number;
readonly integrationMethod?: IntegrationMethod;
}
export interface ApiProps {
readonly deploy?: boolean;
readonly deployOptions?: StageProps;
readonly stageName?: string;
readonly retainDeployments?: boolean;
readonly name?: string;
readonly protocolType?: ProtocolType;
readonly routeSelectionExpression?: KnownRouteKey | string;
readonly apiKeySelectionExpression?: string;
readonly description?: string;
readonly disableSchemaValidation?: boolean;
readonly version?: string;
}
export interface RouteProps {
readonly apiKeyRequired?: boolean;
readonly authorizationScopes?: string[];
readonly authorizationType?: apigw.AuthorizationType;
readonly authorizerId?: apigw.CfnAuthorizerV2;
readonly modelSelectionExpression?: KnownModelKey | string;
readonly operationName?: string;
readonly requestModels?: { [key: string]: Model };
readonly requestParameters?: { [key: string]: boolean };
readonly routeResponseSelectionExpression?: KnownResponseKey | string;
}
export interface DeploymentProps {
readonly description?: string;
readonly stageName?: string;
readonly retainDeployments?: boolean;
}
export interface IntegrationResponseProps {
readonly contentHandlingStrategy?: ContentHandlingStrategy;
readonly responseParameters?: { [key: string]: string };
readonly responseTemplates?: { [key: string]: string };
readonly templateSelectionExpression?: KnownTemplateKey | string;
}
export interface RouteResponseProps {
readonly responseParameters?: { [key: string]: string };
readonly responseModels?: { [key: string]: Model };
readonly modelSelectionExpression?: KnownModelKey | string;
}
export interface ModelProps {
readonly contentType?: KnownContentTypes;
readonly name?: string;
readonly description?: string;
}
export abstract class Integration extends cdk.Resource {
protected api: Api
protected integration: apigw.CfnIntegrationV2
public readonly integrationId: string
constructor(api: Api, id: string, type: IntegrationType, uri: string, props?: IntegrationProps) {
super(api, id);
this.api = api
if (props === undefined) {
props = {};
}
this.integration = new apigw.CfnIntegrationV2(this, 'Resource', {
...props,
apiId: this.api.apiId,
integrationType: type,
integrationUri: uri
});
this.integrationId = this.integration.integrationId
this.api.addToLogicalId({ ...props, id: id, integrationType: type, integrationUri: uri });
this.api.registerDependency(this.integration);
}
public setPermissionsForRoute(route: Route) {
// Override to define permissions for this integration
console.log("Default integration for route: ", route);
}
public addResponse(key: KnownResponseKey | string, props?: IntegrationResponseProps): IntegrationResponse {
return new IntegrationResponse(this, `Response.${key}`, this.api, key, props);
}
public addRoute(key: string, props?: RouteProps): Route {
return new Route(this, `Route.${key}`, key, this.api, props);
}
}
export class Model extends cdk.Resource {
protected api: Api
protected model: apigw.CfnModelV2
public readonly modelId: string
public readonly modelName: string
constructor(api: Api, id: string, schema: object, props?: ModelProps) {
super(api, id);
this.api = api
if (props === undefined) {
props = {};
}
this.modelName = this.node.uniqueId;
this.model = new apigw.CfnModelV2(this, 'Resource', {
...props,
contentType: props.contentType || KnownContentTypes.JSON,
apiId: this.api.apiId,
name: this.modelName,
schema: schema
});
this.modelId = this.model.modelId;
this.api.addToLogicalId({ ...props, id: id, contentType: props.contentType, name: this.modelName, schema: schema });
this.api.registerDependency(this.model);
}
}
export class Deployment extends cdk.Resource {
private hashComponents = new Array<any>();
protected api: Api
protected deployment: apigw.CfnDeploymentV2
public readonly deploymentId: string
constructor(api: Api, id: string, props?: DeploymentProps) {
super(api, id);
this.api = api;
if (props === undefined) {
props = {};
}
this.deployment = new apigw.CfnDeploymentV2(this, 'Resource', {
apiId: this.api.apiId,
description: props.description,
stageName: props.stageName
});
if ((props.retainDeployments === undefined) || (props.retainDeployments === true)) {
this.deployment.options.deletionPolicy = cdk.DeletionPolicy.Retain;
}
this.deploymentId = this.deployment.deploymentId
}
public addToLogicalId(data: unknown) {
if (this.node.locked) {
throw new Error('Cannot modify the logical ID when the construct is locked');
}
this.hashComponents.push(data);
}
public registerDependency(dependency: cdk.CfnResource) {
this.deployment.addDependsOn(dependency);
}
public prepare() {
const stack = Stack.of(this);
const originalLogicalId = stack.getLogicalId(this.deployment);
const md5 = crypto.createHash('md5');
this.hashComponents
.map(c => stack.resolve(c))
.forEach(c => md5.update(JSON.stringify(c)));
this.deployment.overrideLogicalId(originalLogicalId + md5.digest("hex"));
super.prepare();
}
}
export class Stage extends cdk.Resource {
protected api: Api
protected stage: apigw.CfnStageV2
public readonly stageName: string
constructor(api: Api, id: string, name: string, deployment: Deployment, props?: StageProps) {
super(api, id);
this.api = api
if (props === undefined) {
props = {};
}
this.stage = new apigw.CfnStageV2(this, 'Resource', {
apiId: this.api.apiId,
...props,
stageName: name,
deploymentId: deployment.deploymentId
});
this.stageName = this.stage.stageName;
this.api.addToLogicalId({ ...props, id: id, stageName: name });
}
}
export class Route extends cdk.Construct {
protected api: Api
protected integration: Integration
protected route: apigw.CfnRouteV2
public readonly key: string
public readonly routeId: string
constructor(integration: Integration, id: string, key: string | KnownRouteKey, api: Api, props?: RouteProps) {
super(integration, id)
this.integration = integration
this.api = api
this.key = key
if (props === undefined) {
props = {};
}
let authorizerId: string | undefined = undefined;
if (props.authorizerId instanceof apigw.CfnAuthorizerV2) {
authorizerId = `${(props.authorizerId as apigw.CfnAuthorizerV2).authorizerId}`;
} else if (isString(props.authorizerId)) {
authorizerId = `${props.authorizerId}`;
}
let requestModels: { [key: string]: string } | undefined = undefined;
if (props.requestModels !== undefined) {
requestModels = Object.assign({}, ...Object.entries(props.requestModels).map((e) => ({ [e[0]]: e[1].modelName })));
}
this.route = new apigw.CfnRouteV2(this, 'Resource', {
...props,
apiKeyRequired: props.apiKeyRequired,
apiId: this.api.apiId,
routeKey: this.key,
target: `integrations/${this.integration.integrationId}`,
requestModels: requestModels,
authorizerId: authorizerId
});
this.routeId = this.route.routeId
this.integration.setPermissionsForRoute(this);
this.api.addToLogicalId({ ...props, id: id, routeKey: this.key, target: `integrations/${this.integration.integrationId}`, requestModels: requestModels, authorizerId: authorizerId });
this.api.registerDependency(this.route);
}
public addResponse(key: KnownResponseKey | string, props?: RouteResponseProps): RouteResponse {
return new RouteResponse(this, `Response.${key}`, this.api, key, props);
}
}
export class LambdaIntegration extends Integration {
protected handler: lambda.IFunction
constructor(api: Api, id: string, handler: lambda.IFunction, props?: IntegrationProps) {
const stack = Stack.of(api);
const uri = `arn:${stack.partition}:apigateway:${stack.region}:lambda:path/2015-03-31/functions/${handler.functionArn}/invocations`
super(api, id, (props !== undefined && props.proxy !== undefined && props.proxy) ? IntegrationType.AWS_PROXY : IntegrationType.AWS, uri, props);
this.handler = handler
}
public setPermissionsForRoute(route: Route) {
const sourceArn = this.api.executeApiArn(route);
this.handler.addPermission(`ApiPermission.${route.node.uniqueId}`, {
principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
sourceArn: sourceArn
});
}
}
export class IntegrationResponse extends cdk.Resource {
protected api: Api
protected integration: Integration
protected response: apigw.CfnIntegrationResponseV2
constructor(integration: Integration, id: string, api: Api, integrationResponseKey: string, props?: IntegrationResponseProps) {
super(integration, id);
if (props === undefined) {
props = {};
}
this.integration = integration
this.api = api
this.response = new apigw.CfnIntegrationResponseV2(this, 'Resource', {
...props,
apiId: this.api.apiId,
integrationId: integration.integrationId,
integrationResponseKey: integrationResponseKey
});
this.api.addToLogicalId({ ...props, id: id, integrationId: integration.integrationId, integrationResponseKey: integrationResponseKey });
this.api.registerDependency(this.response);
}
}
export class RouteResponse extends cdk.Resource {
protected api: Api
protected route: Route
protected response: apigw.CfnRouteResponseV2
constructor(route: Route, id: string, api: Api, routeResponseKey: string, props?: RouteResponseProps) {
super(route, id);
if (props === undefined) {
props = {};
}
this.route = route
this.api = api
let responseModels: { [key: string]: string } | undefined = undefined;
if (props.responseModels !== undefined) {
responseModels = Object.assign({}, ...Object.entries(props.responseModels).map((e) => ({ [e[0]]: e[1].modelName })));
}
this.response = new apigw.CfnRouteResponseV2(this, 'Resource', {
...props,
apiId: this.api.apiId,
routeId: route.routeId,
routeResponseKey: routeResponseKey,
responseModels: responseModels
});
this.api.addToLogicalId({ ...props, id: id, routeId: route.routeId, routeResponseKey: routeResponseKey, responseModels: responseModels });
this.api.registerDependency(this.response);
}
}
export class Api extends cdk.Resource {
protected api: apigw.CfnApiV2
protected stage: Stage
protected deployment: Deployment
public readonly apiId: string
public readonly stageName: string
constructor(scope: cdk.Construct, id: string, props?: ApiProps) {
super(scope, id);
if (props === undefined) {
props = {};
}
this.api = new apigw.CfnApiV2(this, 'Resource', {
...props,
protocolType: props.protocolType || ProtocolType.WEBSOCKET,
routeSelectionExpression: props.routeSelectionExpression || '${request.body.action}',
name: '@@Error@@'
});
this.api.addPropertyOverride('Name', props.name || this.api.logicalId);
this.apiId = this.api.apiId
if ((props.deploy === true) || (props.deploy === undefined)) {
let stageName = props.stageName || 'prod';
this.deployment = new Deployment(this, 'Deployment', {
description: 'Automatically created by the Api construct'
// No stageName specified, this will be defined by the stage directly, as it will reference the deployment
});
this.stage = new Stage(this, `Stage.${stageName}`, stageName, this.deployment, {
...props.deployOptions,
description: 'Automatically created by the Api construct'
});
this.stageName = this.stage.stageName;
}
}
public addToLogicalId(data: unknown) {
this.deployment.addToLogicalId(data);
}
public registerDependency(dependency: cdk.CfnResource) {
this.deployment.registerDependency(dependency);
}
public executeApiArn(route?: Route | string, stage?: Stage | string) {
const stack = Stack.of(this);
const apiId = this.apiId;
const routeKey = ((route === undefined) ? '*' : (typeof (route) == "string" ? (route as string) : (route as Route).key));
const stageName = ((stage === undefined) ? this.stageName : (typeof (stage) == "string" ? (stage as string) : (stage as Stage).stageName));
return stack.formatArn({
service: 'execute-api',
resource: apiId,
sep: '/',
resourceName: `${stageName}/${routeKey}`
});
}
public connectionsApiArn(connectionId: string = "*", stage?: Stage | string) {
const stack = Stack.of(this);
const apiId = this.apiId;
const stageName = ((stage === undefined) ? this.stageName : (typeof (stage) == "string" ? (stage as string) : (stage as Stage).stageName));
return stack.formatArn({
service: 'execute-api',
resource: apiId,
sep: '/',
resourceName: `${stageName}/POST/${connectionId}`
});
}
public clientUrl(): string {
const stack = Stack.of(this);
return `wss://${this.apiId}.execute-api.${stack.region}.amazonaws.com/${this.stageName}`;
}
public connectionsUrl(): string {
const stack = Stack.of(this);
return `https://${this.apiId}.execute-api.${stack.region}.amazonaws.com/${this.stageName}/@connections`;
}
public addLambdaIntegration(id: string, handler: lambda.IFunction, props?: IntegrationProps): Integration {
return new LambdaIntegration(this, id, handler, props);
}
public addModel(schema: SchemaDefinition, props?: ModelProps): Model {
return new Model(this, `Model.${schema.title}`, schema, props);
}
}
And the usage:
import cdk = require('@aws-cdk/cdk');
import { Api, PassthroughBehavior, KnownResponseKey, KnownRouteKey, Route, LoggingLevel, IntegrationResponseProps, RouteResponseProps } from './websocket-api';
import { Construct } from '@aws-cdk/cdk';
import { Function, Runtime, Code } from '@aws-cdk/aws-lambda'
import { Table } from '@aws-cdk/aws-dynamodb';
import { PolicyStatement } from '@aws-cdk/aws-iam';
import { AuthorizationType } from '@aws-cdk/aws-apigateway';
export interface WebSocketProps {
readonly apiCode: Code;
readonly pollsTable: Table;
readonly scoresTable: Table;
readonly votesTable: Table;
readonly usersTable: Table;
readonly connectionsTable: Table;
readonly userFunction: Function;
readonly voteFunction: Function;
}
export class WebSocket extends cdk.Resource {
private api: Api;
private publicRoutes = new Array<Route>();
public clientUrl(): string {
return this.api.clientUrl();
}
public connectionsUrl(): string {
return this.api.connectionsUrl();
}
public connectionsApiArn(): string {
return this.api.connectionsApiArn();
}
public publicRoutesArn(): Array<string> {
const api = this.api;
return this.publicRoutes.map((r) => api.executeApiArn(r));
}
private addPublicRoute(route: Route) {
this.publicRoutes.push(route);
return route;
}
constructor(scope: Construct, id: string, props: WebSocketProps) {
super(scope, id);
// WebSocket API
this.api = new Api(this, 'Resource', {
routeSelectionExpression: '${request.body.action}',
deployOptions: {
defaultRouteSettings: {
dataTraceEnabled: true,
loggingLevel: LoggingLevel.DEBUG
}
}
});
const webSocketFunction = new Function(this, 'WebSocketFunction', {
runtime: Runtime.Nodejs10x,
code: props.apiCode,
timeout: 300,
handler: "WebSocket.handler",
environment: {
POLLS_TABLE: props.pollsTable.tableName,
SCORES_TABLE: props.scoresTable.tableName,
VOTES_TABLE: props.votesTable.tableName,
USERS_TABLE: props.usersTable.tableName,
CONNECTIONS_TABLE: props.connectionsTable.tableName,
USER_FUNCTION: props.userFunction.functionName,
VOTE_FUNCTION: props.voteFunction.functionName
}
});
webSocketFunction.addToRolePolicy(new PolicyStatement({ resources: [ props.pollsTable.tableArn, props.usersTable.tableArn, props.votesTable.tableArn, props.scoresTable.tableArn, props.votesTable.tableArn + "/*", props.usersTable.tableArn + '/*', props.connectionsTable.tableArn, props.connectionsTable.tableArn + '/*' ], actions: [ 'dynamodb:*' ] }));
webSocketFunction.addToRolePolicy(new PolicyStatement({ resources: [ this.api.connectionsApiArn('*', '*') ], actions: [ 'execute-api:Invoke', 'execute-api:ManageConnections' ] }));
webSocketFunction.addToRolePolicy(new PolicyStatement({ resources: [ props.userFunction.functionArn, props.voteFunction.functionArn ], actions: [ 'lambda:InvokeFunction' ] }));
const defaultStatusIntegrationResponse: IntegrationResponseProps = {
responseTemplates: {
'default': '#set($inputRoot = $input.path(\'$\')) { "status": "${inputRoot.status}", "message": "$util.escapeJavaScript(${inputRoot.message})" }'
},
templateSelectionExpression: "default"
};
const webSocketConnectIntegration = this.api.addLambdaIntegration('ConnectIntegration', webSocketFunction, {
proxy: false,
passthroughBehavior: PassthroughBehavior.NEVER,
requestTemplates: {
"connect": '{ "action": "${context.routeKey}", "userId": "${context.identity.cognitoIdentityId}", "connectionId": "${context.connectionId}", "domainName": "${context.domainName}", "stageName": "${context.stage}" }'
},
templateSelectionExpression: 'connect',
description: 'Quizz WebSocket Api Connection Integration'
});
webSocketConnectIntegration.addResponse(KnownResponseKey.DEFAULT, defaultStatusIntegrationResponse);
const webSocketDisconnectIntegration = this.api.addLambdaIntegration('DisconnectIntegration', webSocketFunction, {
proxy: false,
passthroughBehavior: PassthroughBehavior.NEVER,
requestTemplates: {
"disconnect": '{ "action": "${context.routeKey}", "connectionId": "${context.connectionId}", "domainName": "${context.domainName}", "stageName": "${context.stage}" }'
},
templateSelectionExpression: 'disconnect',
description: 'Quizz WebSocket Api Connection Integration'
});
webSocketDisconnectIntegration.addResponse(KnownResponseKey.DEFAULT, defaultStatusIntegrationResponse);
const webSocketIntegration = this.api.addLambdaIntegration('DefaultIntegration', webSocketFunction, {
proxy: false,
passthroughBehavior: PassthroughBehavior.NEVER,
requestTemplates: {
"selectPoll": '#set($inputRoot = $input.path(\'$\'))\n{ "action": "${inputRoot.action}", "pollId": "${inputRoot.pollId}", "connectionId": "${context.connectionId}" }',
"setName": '#set($inputRoot = $input.path(\'$\'))\n{ "action": "${inputRoot.action}", "name": "$util.escapeJavaScript(${inputRoot.name})", "connectionId": "${context.connectionId}" }',
"vote": '#set($inputRoot = $input.path(\'$\'))\n{ "action": "${inputRoot.action}", "questionId": ${inputRoot.questionId}, "answer": "$util.escapeJavaScript(${inputRoot.answer})", "connectionId": "${context.connectionId}" }',
"status": '#set($inputRoot = $input.path(\'$\'))\n{ "action": "${inputRoot.action}", "connectionId": "${context.connectionId}" }'
},
templateSelectionExpression: '${request.body.action}',
description: 'Quizz WebSocket Api'
});
webSocketIntegration.addResponse(KnownResponseKey.DEFAULT, defaultStatusIntegrationResponse);
const defaultStatusRouteResponse: RouteResponseProps = {
modelSelectionExpression: "default",
responseModels: {
'default': this.api.addModel({ "$schema": "http://json-schema.org/draft-04/schema#", "title": "statusResponse", "type": "object", "properties": { "status": { "type": "string" }, "message": { "type": "string" } } })
}
};
this.addPublicRoute(webSocketConnectIntegration.addRoute(KnownRouteKey.CONNECT, {
authorizationType: AuthorizationType.IAM,
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
this.addPublicRoute(webSocketDisconnectIntegration.addRoute(KnownRouteKey.DISCONNECT, {
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
this.addPublicRoute(webSocketIntegration.addRoute('selectPoll', {
requestModels: {
"selectPoll": this.api.addModel({ "$schema": "http://json-schema.org/draft-04/schema#", "title": "selectPollInputModel", "type": "object", "properties": { "action": { "type": "string" }, "pollId": { "type": "string" } } })
},
modelSelectionExpression: "selectPoll",
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
this.addPublicRoute(webSocketIntegration.addRoute('setName', {
requestModels: {
"setName": this.api.addModel({ "$schema": "http://json-schema.org/draft-04/schema#", "title": "setNameInputModel", "type": "object", "properties": { "action": { "type": "string" }, "name": { "type": "string" } } })
},
modelSelectionExpression: "setName",
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
this.addPublicRoute(webSocketIntegration.addRoute('vote', {
requestModels: {
"vote": this.api.addModel({ "$schema": "http://json-schema.org/draft-04/schema#", "title": "voteInputModel", "type": "object", "properties": { "action": { "type": "string" }, "questionId": { "type": "integer" }, "answer": { "type": "string" } } })
},
modelSelectionExpression: "vote",
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
this.addPublicRoute(webSocketIntegration.addRoute('status', {
requestModels: {
"status": this.api.addModel({ "$schema": "http://json-schema.org/draft-04/schema#", "title": "statusInputModel", "type": "object", "properties": { "action": { "type": "string" } } })
},
modelSelectionExpression: "status",
routeResponseSelectionExpression: KnownResponseKey.DEFAULT
})).addResponse(KnownResponseKey.DEFAULT, defaultStatusRouteResponse);
}
}
I have some time now, anyone wants to take it, or are you happy for me to re-own it?
@nija-at is currently the maintainer of the apigateway module and I think started to think about v2. You guys should sync up.
Is there a timeframe for when this will be worked on? Will this take a couple more months or could there be a preview out within a few weeks? Would love to see this happen!
Is there a timeframe for when this will be worked on? Will this take a couple more months or could there be a preview out within a few weeks? Would love to see this happen!
same, waiting for this.
Unfortunately, we've not had a chance to get to this as yet, and don't have a timeframe.
@spaceemotion, @binarythinktank and anyone who has 👍'ed the comment, please do the same on the main issue description, so it gets the right priority. Thanks!
@BrianG13 , thanks a lot for your python example, it helped me a lot! I am running with my TS implementation into the exact same problem that you describe:
Another problem is that, I can't create&deploy at the same "cdk deploy hello-brian" command.
I have to deploy first without creating a stage+deployment, the routes are created.
and only on second time that I run "cdk deploy hello-brian" command api is deploy.. how this can be fixed??
Did you ever manage to resolve that specific problem? I currently need to comment out the following two lines before I initially deploy, otherwise I'll get At least one route is required before deploying the Api.
:
const deployment = new apigateway.CfnDeploymentV2(this, 'wss-deployment', {
apiId: eventsApi.ref
})
new apigateway.CfnStageV2(this, 'wss-stage-test', {
apiId: eventsApi.ref,
stageName: 'test',
deploymentId: deployment.ref
})
Update, I think I figured it out: deployment.addDependsOn(<CfnRoute>);
did the trick.
Hello there,
I'm trying to use WebSocket via VPC_LINK, are the "ConnectionId" params missing from CDK or is not managed through CloudFormation?
Error in CDK version 1.28:
ConnectionId must be set to vpcLinkId for ConnectionType VPC_LINK (Service: AmazonApiGatewayV2; Status Code: 400; Error Code: BadRequestException; Request ID: 5e67bbc9-d1fa-464d-a4a1-7fbfa7ef3fef)
Hi there, is there a working cdk WebSocket with API gateway example I could look at please?
@bhupendra-bhudia try the below.
Disclaimer: this is extracted from one of my projects where i have my own classes abstracting and extending CDK. For the code below, I removed all references to my classes but the result is untested. it should provide you with enough info to get it working though. Note also that this was created about 6 months ago, and CDK changes frequently.
const lambdaAPIsocket = CREATE_YOUR_LAMBDA_AS_PER_USUAL;
// MAIN API ------------------------------------------------------------------
// websocket api
const apigatewaysocket = new apigateway.CfnApiV2(this, "apigatewaysocket", {
name: "WDISSockets",
protocolType: "WEBSOCKET",
routeSelectionExpression: "$request.body.message"
});
// access role for the socket api to access the socket lambda
const policy = new iam.PolicyStatement({
effect: stack.services.iam.Effect.ALLOW,
resources: [lambdaAPIsocket.functionArn],
actions: ["lambda:InvokeFunction"]
});
const roleapigatewaysocketapi = new iam.Role(this, "roleapigatewaysocketapi", {
assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com")
});
roleapigatewaysocketapi.addToPolicy(policy.apigatewaysocketapi);
// API ROUTES ------------------------------------------------------------------
// connect route
const apigatewayroutesocketconnect = new apigateway.CfnRouteV2(this, "apigatewayroutesocketconnect", {
apiId:apigatewaysocket.ref,
routeKey: "$connect",
authorizationType: "NONE",
operationName: "ConnectRoute",
target: "integrations/"+new apigateway.CfnIntegrationV2(this, "apigatewayintegrationsocketconnect", {
apiId:apigatewaysocket.ref,
integrationType: "AWS_PROXY",
integrationUri: "arn:aws:apigateway:"+region+":lambda:path/2015-03-31/functions/"+lambdaAPIsocket.functionArn+"/invocations",
credentialsArn: roleapigatewaysocketapi.roleArn
}).ref
}));
// disconnect route
const apigatewayroutesocketdisconnect = new apigateway.CfnRouteV2(this, "apigatewayroutesocketdisconnect", {
apiId:apigatewaysocket.ref,
routeKey: "$disconnect",
authorizationType: "NONE",
operationName: "DisconnectRoute",
target: "integrations/"+new apigateway.CfnIntegrationV2(this, "apigatewayintegrationsocketdisconnect", {
apiId:apigatewaysocket.ref,
integrationType: "AWS_PROXY",
integrationUri: "arn:aws:apigateway:"+region+":lambda:path/2015-03-31/functions/"+lambdaAPIsocket.functionArn+"/invocations",
credentialsArn: roleapigatewaysocketapi.roleArn
}).ref
}));
// message route
const apigatewayroutesocketdefault = new apigateway.CfnRouteV2(this, "apigatewayroutesocketdefault", {
apiId:apigatewaysocket.ref,
routeKey: "$default",
authorizationType: "NONE",
operationName: "SendRoute",
target: "integrations/"+new apigateway.CfnIntegrationV2(this, "apigatewayintegrationsocketdefault", {
apiId:apigatewaysocket.ref,
integrationType: "AWS_PROXY",
integrationUri: "arn:aws:apigateway:"+region+":lambda:path/2015-03-31/functions/"+lambdaAPIsocket.functionArn+"/invocations",
credentialsArn: roleapigatewaysocketapi.roleArn
}).ref
}));
// DEPLOY ------------------------------------------------------------------
// deployment
const apigatewaydeploymentsocket = new apigateway.CfnDeploymentV2(this, "apigatewaydeploymentsocket", {
apiId:apigatewaysocket.ref
}));
// stage
const apigatewaystagesocket = new apigateway.CfnStageV2(this, "apigatewaystagesocket", {
apiId:apigatewaysocket.ref,
deploymentId: apigatewaydeploymentsocket.ref,
stageName: "prod",
defaultRouteSettings:{
throttlingBurstLimit: 500,
throttlingRateLimit: 1000
}
}));
// all the routes are dependencies of the deployment
const routes = new stack.cdk.ConcreteDependable();
routes.add(apigatewayroutesocketconnect);
routes.add(apigatewayroutesocketdisconnect);
routes.add(apigatewayroutesocketdefault);
// Add the dependency
apigatewaydeploymentsocket.node.addDependency(routes);
// CUSTOM API DOMAIN ------------------------------------------------------------------
// custom domain
const apigatewaydomainsocket = new apigateway.CfnDomainNameV2(this, "apigatewaydomainsocket", {
domainName: "sockets.example.com",
domainNameConfigurations:[{
certificateArn: your_cert_arn,
endpointType: apigateway.EndpointType.REGIONAL
}]
}));
const apigatewaymappingsocket = new apigateway.CfnBasePathMapping(this, "apigatewaymappingsocket", {
domainName:apigatewaydomainsocket.ref,
restApiId:apigatewaysocket.ref,
stage: apigatewaystagesocket.ref
}));
// create the subdomain
const route53socket = new route53.CnameRecord(this, "route53socket", {
recordName: "ws",
zone:route53.hostedzone,
domainName: apigatewaydomainsocket.attrRegionalDomainName
}));
@binarythinktank thank you - I shall take a look at what you've shared.
@binarythinktank , I created a stack as explained in your latest example. It worked, thanks.
glad to hear it!
@binarythinktank Thanks for sharing that. It helped me a lot. How do you get the stage endpoint out of this construct so it can be passed as env var into lambda? Can it be constructed using .logicalId
?
@ali-habibzadeh not needed that before, you could try apigatewaystagesocket.ref ?
@ali-habibzadeh not needed that before, you could try apigatewaystagesocket.ref ?
I was just confusing myself. If someone else ended up here not knowing how to pass the end point to manager:
const { requestContext: { domainName, stage } } = this.event;
this.manager = new ApiGatewayManagementApi({ endpoint: `https://${domainName}/${stage}` });
I feel I am almost there... Can anyone spot the error?
I am using 1.47.0
of the cdk
12:20:27 | CREATE_FAILED | AWS::IAM::Role | roleapigatewaysocketapi
Invalid principal in policy: "SERVICE":"apigw.amazonaws.com" (Service: AmazonIdentityManagement; Status Code: 400; Error Code: MalformedPolicyDocument; Request ID: deadbeef-9c7f-4159-8034-ecdb94d
4530e)
the code is:
const lambdaAPIsocket = new lambda.Function(this, 'WebSocketHandler', {
runtime: lambda.Runtime.NODEJS_10_X, // execution environment
code: lambda.Code.fromAsset('backend/lambda'),
handler: 'websocket.handler'
});
// access role for the socket api to access the socket lambda
const policy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
resources: [lambdaAPIsocket.functionArn],
actions: ["lambda:InvokeFunction"]
});
const roleapigatewaysocketapi = new iam.Role(this, "roleapigatewaysocketapi", {
assumedBy: new iam.ServicePrincipal("apigw.amazonaws.com")
});
roleapigatewaysocketapi.addToPolicy(policy);
@wzr1337 principal is apigateway.amazonaws.com
search and replace at its best i guess :D
I will give it another try now
Thx!
Edit: Fix it!!
Thanks for sharing your code @binarythinktank, but CfnApiV2
, CfnRouteV2
, ... are marked as deprecated. Did you end up updating it with aws-apigatewayv2
somehow ?
@nija-at Thanks for your work. Do you have an update or a time frame to have the higher level construct for web sockets ? And a CDK sample would be awesome.
@tuanardouin not yet, though if they have deprecated those then it should now be possible. i might revisit it at some point and will update the code here if i do, don't wait for it though :)
@binarythinktank Thanks for sharing example. I have spent sometime to write sample app which supports apigatewayv2 and made pull request to aws-cdk-examples. PR is here. It supports latestest version of cdk. The sample is original from simple-websockets-chat-app, I took out the sam template and convert it to cdk. @tuanardouin I hope sample app helps you.
@ypwu1 awesome, i suppose i should go an update the site i had my old implementation in now :)
@ypwu1 was finally able to test this with a new project and it works like a charm :) thanks again.
Hey guys,
Here is the websocket construct I created:
import * as cdk from '@aws-cdk/core'
import * as dynamodb from '@aws-cdk/aws-dynamodb'
import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2'
import * as nodejs from '@aws-cdk/aws-lambda-nodejs'
import * as lambda from '@aws-cdk/aws-lambda'
import * as logs from '@aws-cdk/aws-logs'
import * as iam from '@aws-cdk/aws-iam'
export interface Props {
prefix: string
region: string
account_id: string
}
/**
* Websocket API composed from L1 constructs since
* aws has yet to release any L2 constructs.
*/
export class WebsocketConstruct extends cdk.Construct {
constructor(parent: cdk.Construct, id: string, props: Props) {
super(parent, id)
// table where websocket connections will be stored
const websocket_table = new dynamodb.Table(this, 'connection-table', {
tableName: props?.prefix + 'connection-table',
partitionKey: {
name: 'connection_id',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PROVISIONED,
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecovery: true,
writeCapacity: 5,
readCapacity: 5,
})
// initialize api
const name = id + '-api'
const websocket_api = new apigatewayv2.CfnApi(this, name, {
name: 'websockets',
protocolType: 'WEBSOCKET',
routeSelectionExpression: '$request.body.action',
})
// initialize lambda and permissions
const lambda_policy = new iam.PolicyStatement({
actions: [
'dynamodb:GetItem',
'dynamodb:DeleteItem',
'dynamodb:PutItem',
'dynamodb:Scan',
'dynamodb:Query',
'dynamodb:UpdateItem',
'dynamodb:BatchWriteItem',
'dynamodb:BatchGetItem',
'dynamodb:DescribeTable',
'dynamodb:ConditionCheckItem',
],
resources: [websocket_table.tableArn],
})
const connect_lambda_role = new iam.Role(this, 'connect-lambda-role', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
})
connect_lambda_role.addToPolicy(lambda_policy)
connect_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
'service-role/AWSLambdaBasicExecutionRole'
)
)
const disconnect_lambda_role = new iam.Role(
this,
'disconnect-lambda-role',
{ assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com') }
)
disconnect_lambda_role.addToPolicy(lambda_policy)
disconnect_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
'service-role/AWSLambdaBasicExecutionRole'
)
)
const message_lambda_role = new iam.Role(this, 'message-lambda-role', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
})
message_lambda_role.addToPolicy(lambda_policy)
message_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
'service-role/AWSLambdaBasicExecutionRole'
)
)
const connect_lambda = new nodejs.NodejsFunction(this, 'connect_lambda', {
handler: 'handler',
functionName: props?.prefix + 'connect',
description: 'Connect a user.',
timeout: cdk.Duration.seconds(300),
entry: './endpoints/onconnect/index.ts',
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: connect_lambda_role,
environment: {
TABLE_NAME: websocket_table.tableName,
},
})
const disconnect_lambda = new nodejs.NodejsFunction(
this,
'disconnect_lambda',
{
handler: 'handler',
functionName: props?.prefix + 'disconnect',
description: 'Disconnect a user.',
timeout: cdk.Duration.seconds(300),
entry: './endpoints/ondisconnect/index.ts',
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: disconnect_lambda_role,
environment: {
TABLE_NAME: websocket_table.tableName,
},
}
)
const message_lambda = new nodejs.NodejsFunction(this, 'message-lambda', {
handler: 'handler',
functionName: props?.prefix + 'send-message',
description: 'Disconnect a user.',
timeout: cdk.Duration.seconds(300),
entry: './endpoints/send-message/index.ts',
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: message_lambda_role,
initialPolicy: [
new iam.PolicyStatement({
actions: ['execute-api:ManageConnections'],
resources: [
this.create_resource_str(
props.account_id,
props.region,
websocket_api.ref
),
],
effect: iam.Effect.ALLOW,
}),
],
environment: {
TABLE_NAME: websocket_table.tableName,
},
})
// access role for the socket api to access the socket lambda
const policy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
resources: [
connect_lambda.functionArn,
disconnect_lambda.functionArn,
message_lambda.functionArn,
],
actions: ['lambda:InvokeFunction'],
})
const role = new iam.Role(this, `${name}-iam-role`, {
assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
})
role.addToPolicy(policy)
// websocket api lambda integration
const connect_integration = new apigatewayv2.CfnIntegration(
this,
'connect-lambda-integration',
{
apiId: websocket_api.ref,
integrationType: 'AWS_PROXY',
integrationUri: this.create_integration_str(
props.region,
connect_lambda.functionArn
),
credentialsArn: role.roleArn,
}
)
const disconnect_integration = new apigatewayv2.CfnIntegration(
this,
'disconnect-lambda-integration',
{
apiId: websocket_api.ref,
integrationType: 'AWS_PROXY',
integrationUri: this.create_integration_str(
props.region,
disconnect_lambda.functionArn
),
credentialsArn: role.roleArn,
}
)
const message_integration = new apigatewayv2.CfnIntegration(
this,
'message-lambda-integration',
{
apiId: websocket_api.ref,
integrationType: 'AWS_PROXY',
integrationUri: this.create_integration_str(
props.region,
message_lambda.functionArn
),
credentialsArn: role.roleArn,
}
)
// Example route definition
const connect_route = new apigatewayv2.CfnRoute(this, 'connect-route', {
apiId: websocket_api.ref,
routeKey: '$connect',
authorizationType: 'NONE',
target: 'integrations/' + connect_integration.ref,
})
const disconnect_route = new apigatewayv2.CfnRoute(
this,
'disconnect-route',
{
apiId: websocket_api.ref,
routeKey: '$disconnect',
authorizationType: 'NONE',
target: 'integrations/' + disconnect_integration.ref,
}
)
const message_route = new apigatewayv2.CfnRoute(this, 'message-route', {
apiId: websocket_api.ref,
routeKey: 'sendmessage',
authorizationType: 'NONE',
target: 'integrations/' + message_integration.ref,
})
// Finishing touches on the API definition
const deployment = new apigatewayv2.CfnDeployment(
this,
`${name}-deployment`,
{ apiId: websocket_api.ref }
)
new apigatewayv2.CfnStage(this, `${name}-stage`, {
apiId: websocket_api.ref,
autoDeploy: true,
deploymentId: deployment.ref,
stageName: 'dev',
})
const dependencies = new cdk.ConcreteDependable()
dependencies.add(connect_route)
dependencies.add(disconnect_route)
dependencies.add(message_route)
deployment.node.addDependency(dependencies)
}
private create_integration_str = (region: string, fn_arn: string): string =>
`arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${fn_arn}/invocations`
private create_resource_str = (
account_id: string,
region: string,
ref: string
): string => `arn:aws:execute-api:${region}:${account_id}:${ref}/*`
}
Then in your main app, use like:
new WebsocketConstruct(this, 'websockets', {
prefix: props?.prefix ?? '',
account_id: this.account,
region: this.region,
})
I still need to add the ability to add custom domain, which I will base off of the code provided by @binarythinktank.
Keep spreading knoweledge guys! And if you find a way to improve this construct, please post here!
Thank you @binarythinktank and @55Cancri. Based on your work, the project simple-websockets-chat-app and the code from @mrpackethead I was able to put it all together in CDK for Python :
https://github.com/tuanardouin/WebSocket-CDK
I prefer to use TypeScript when using CDK so I'll do that next.
forgive me if I might have missed some explanation, but is it an expected behaviour that the "integration response" button has to be pressed in the routes portion of the interface after the stack has been deployed for the first time, for each web-socket methods (connect, disconnect, sendmessage) ?
@intersides Not for me. Did you add anything to the CfnApi
when creating it ?
No, I am pretty much following the same latest examples as @binarythinktank (without v2 since I am using 1.75.0 ) and @55Cancri .
I am adding some code sample mentioning only the sendMessage route for simplification.
const webSocketsApi = new CfnApi(this, `${config.service_name}_websocket-api`, {
name:`${config.service_name}WSApi`,
protocolType:"WEBSOCKET",
routeSelectionExpression:"$request.body.action"
});
const messageFunc = new Function(this, `${config.service_name}_message-lambda`, {
code:Code.fromAsset(lambdaPathWebsockets),
handler:"sendmessage.handler",
runtime:Runtime.NODEJS_12_X,
role:messageLambdaRole,
initialPolicy:[ new PolicyStatement({
actions:[
"execute-api:ManageConnections"
],
resources:[
"arn:aws:execute-api:" + this.region + ":" + this.account + ":" + webSocketsApi.ref + "/*"
],
effect:Effect.ALLOW,
})
]
});
.....
const messageIntegration = new CfnIntegration(this, `message-lambda-integration`, {
apiId:webSocketsApi.ref,
integrationType:"AWS_PROXY",
integrationUri:"arn:aws:apigateway:" + this.region + ":lambda:path/2015-03-31/functions/" + messageFunc.functionArn + "/invocations",
credentialsArn:role.roleArn
});
....
const messageRoute = new CfnRoute(this, `message-route`, {
apiId:webSocketsApi.ref,
routeKey:"sendmessage",
authorizationType:"NONE",
target:"integrations/" + messageIntegration.ref,
});
....
const deployment = new CfnDeployment(this, `webSocketApi-deployment`, {
apiId:webSocketsApi.ref
});
new CfnStage(this, `webSocketApi-stage`, {
apiId:webSocketsApi.ref,
autoDeploy:true,
deploymentId:deployment.ref,
stageName:"dev"
});
const dependencies = new ConcreteDependable();
....
dependencies.add(messageRoute);
deployment.node.addDependency(dependencies);
....
@nija-at , it will be great to provide a full working sample using websockets and custom domain. With 1.17 it seems that the code proposed by @bhupendra-bhudia does not fully worked, since CfnApiV2 is now deprecated and I cannot find replacement for CfnBasePathMapping in aws-apigatewayv2. If I still use it with the new aws-apigatewayv2 I will get a : Mixing of REST APIs and HTTP APIs on the same domain name can only be accomplished through API Gateway's V2 DomainName interface.
This is my latest version of my websocket construct that includes custom domain. Websockets endpoint requires a different subdomain. So if my https endpoint is https://www.api.mydomain.com
, then my websocket would something like wss://socket.mydomain.com
. Note also that the stage name is not included (.e.g. mydomain.com/v1
) when accessing websockets from your custom domain.
In order for the construct to use the custom domain, you must provide your domain name, the websocket endpoint, and the arn to an existing acm certificate. If you don't, it will just use the generated endpoint.
At first, I was having the construct create and destroy acm certificates when creating and destroying the stack, but there is a limit of 20 acm certificates you can have, and that number is incremented with each stack creation. At 20, you wouldn't be able to create another certificate, so now I create my acm certs outside of my stack, then pass the arn as props. You will need to do the same if you intend to use this construct as is. If I need to cover more subdomains like environment specific endpoints, I recreate the cert manually.
@intersides I ran into that base path mapping issue too. My solution is included in the code below. Hopefully this addresses your issue.
import * as path from "path";
import * as cdk from "@aws-cdk/core";
import * as dynamodb from "@aws-cdk/aws-dynamodb";
import * as apigw from "@aws-cdk/aws-apigateway";
import * as apigwv2 from "@aws-cdk/aws-apigatewayv2";
import * as alias from "@aws-cdk/aws-route53-targets";
import * as route53 from "@aws-cdk/aws-route53";
import * as lambda from "@aws-cdk/aws-lambda";
import * as logs from "@aws-cdk/aws-logs";
import * as iam from "@aws-cdk/aws-iam";
import * as Index from "../../constants/secondary-indexes";
export interface Props {
prefix: string;
region: string;
account_id: string;
cert_arn?: string;
site_domain_name?: string;
wss_domain_name?: string;
environment?: Record<string, string>;
}
/**
* Websocket API composed from L1 constructs since
* aws has yet to release any L2 constructs.
*/
export class WebSocketConstruct extends cdk.Construct {
public readonly connect_fn: lambda.Function;
public readonly disconnect_fn: lambda.Function;
public readonly message_fn: lambda.Function;
/** role needed to send messages to websocket clients */
public readonly apigw_role: iam.Role;
public readonly CONN_TABLE_NAME: string;
private connection_table: dynamodb.Table;
constructor(parent: cdk.Construct, id: string, props: Props) {
super(parent, id);
const route_selection_key = "action";
const route_selection_value = "sendmessage";
// table where websocket connections will be stored
const websocket_table = new dynamodb.Table(this, "connections", {
tableName: props?.prefix + "connections",
partitionKey: {
name: "room_id",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "connection_id",
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PROVISIONED,
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecovery: true,
writeCapacity: 5,
readCapacity: 5,
});
websocket_table.addGlobalSecondaryIndex({
indexName: Index.names.conn_table.conn_by_conn_id,
partitionKey: {
name: Index.keys.conn_table.conn_by_conn_id_sk,
type: dynamodb.AttributeType.STRING,
},
});
websocket_table.addGlobalSecondaryIndex({
indexName: Index.names.conn_table.conn_by_user_id,
partitionKey: {
name: Index.keys.conn_table.conn_by_user_id_sk,
type: dynamodb.AttributeType.STRING,
},
});
websocket_table.addGlobalSecondaryIndex({
indexName: Index.names.conn_table.conn_by_ip_addr,
partitionKey: {
name: Index.keys.conn_table.conn_by_ip_addr_sk,
type: dynamodb.AttributeType.STRING,
},
});
this.CONN_TABLE_NAME = websocket_table.tableName;
// initialize api
const name = id + "-api";
const websocket_api = new apigwv2.CfnApi(this, name, {
name: "websockets",
protocolType: "WEBSOCKET",
routeSelectionExpression: `$request.body.${route_selection_key}`,
// basePath: "v1",
});
const base_permissions = websocket_table.tableArn;
const index_permissions = `${base_permissions}/index/*`;
// initialize lambda and permissions
const lambda_policy = new iam.PolicyStatement({
// "dynamodb:*" also works here
actions: [
"dynamodb:GetItem",
"dynamodb:DeleteItem",
"dynamodb:PutItem",
"dynamodb:Scan",
"dynamodb:Query",
"dynamodb:UpdateItem",
"dynamodb:BatchWriteItem",
"dynamodb:BatchGetItem",
"dynamodb:DescribeTable",
"dynamodb:ConditionCheckItem",
],
resources: [base_permissions, index_permissions],
});
const connect_lambda_role = new iam.Role(this, "connect-lambda-role", {
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
});
connect_lambda_role.addToPolicy(lambda_policy);
connect_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
)
);
const disconnect_lambda_role = new iam.Role(
this,
"disconnect-lambda-role",
{ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com") }
);
disconnect_lambda_role.addToPolicy(lambda_policy);
disconnect_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
)
);
const message_lambda_role = new iam.Role(this, "message-lambda-role", {
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
});
message_lambda_role.addToPolicy(lambda_policy);
message_lambda_role.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
)
);
const resource_str = this.create_resource_str(
props.account_id,
props.region,
websocket_api.ref
);
const execute_apigw_policy = [
new iam.PolicyStatement({
actions: ["execute-api:Invoke", "execute-api:ManageConnections"],
resources: [resource_str],
effect: iam.Effect.ALLOW,
}),
];
const connect_lambda = new lambda.Function(this, "connect_lambda", {
handler: "index.handler",
functionName: props?.prefix + "connect",
description: "Connect a user.",
timeout: cdk.Duration.seconds(300),
code: lambda.Code.fromAsset(
path.join(__dirname, "../..", "/dist/onconnect")
),
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: connect_lambda_role,
environment: {
...props.environment,
CONN_TABLE_NAME: websocket_table.tableName,
},
});
const disconnect_lambda = new lambda.Function(this, "disconnect_lambda", {
handler: "index.handler",
functionName: props?.prefix + "disconnect",
description: "Disconnect a user.",
timeout: cdk.Duration.seconds(300),
code: lambda.Code.fromAsset(
path.join(__dirname, "../..", "/dist/ondisconnect")
),
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: disconnect_lambda_role,
environment: {
...props.environment,
CONN_TABLE_NAME: websocket_table.tableName,
},
});
const message_lambda = new lambda.Function(this, "message-lambda", {
handler: "index.handler",
functionName: props?.prefix + "send-message",
description: "Send a message to all connected clients.",
timeout: cdk.Duration.seconds(300),
code: lambda.Code.fromAsset(
path.join(__dirname, "../..", "/dist/send-message")
),
runtime: lambda.Runtime.NODEJS_12_X,
logRetention: logs.RetentionDays.FIVE_DAYS,
role: message_lambda_role,
initialPolicy: execute_apigw_policy,
environment: {
CONN_TABLE_NAME: websocket_table.tableName,
...props.environment,
},
});
// access role for the socket api to access the socket lambda
const policy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
resources: [
connect_lambda.functionArn,
disconnect_lambda.functionArn,
message_lambda.functionArn,
],
actions: ["lambda:InvokeFunction"],
});
const role = new iam.Role(this, `${name}-iam-role`, {
assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
});
role.addToPolicy(policy);
// websocket api lambda integration
const connect_integration = new apigwv2.CfnIntegration(
this,
"connect-lambda-integration",
{
apiId: websocket_api.ref,
integrationType: "AWS_PROXY",
integrationUri: this.create_integration_str(
props.region,
connect_lambda.functionArn
),
credentialsArn: role.roleArn,
}
);
const disconnect_integration = new apigwv2.CfnIntegration(
this,
"disconnect-lambda-integration",
{
apiId: websocket_api.ref,
integrationType: "AWS_PROXY",
integrationUri: this.create_integration_str(
props.region,
disconnect_lambda.functionArn
),
credentialsArn: role.roleArn,
}
);
const message_integration = new apigwv2.CfnIntegration(
this,
"message-lambda-integration",
{
apiId: websocket_api.ref,
integrationType: "AWS_PROXY",
integrationUri: this.create_integration_str(
props.region,
message_lambda.functionArn
),
credentialsArn: role.roleArn,
}
);
// Example route definition
const connect_route = new apigwv2.CfnRoute(this, "connect-route", {
apiId: websocket_api.ref,
routeKey: "$connect",
authorizationType: "NONE",
target: "integrations/" + connect_integration.ref,
});
const disconnect_route = new apigwv2.CfnRoute(this, "disconnect-route", {
apiId: websocket_api.ref,
routeKey: "$disconnect",
authorizationType: "NONE",
target: "integrations/" + disconnect_integration.ref,
});
/**
* On the client, you must send messages in the following way:
* JSON.stringify({ "action": "sendmessage", "data": "hello world" })
*/
const message_route = new apigwv2.CfnRoute(this, "message-route", {
apiId: websocket_api.ref,
routeKey: route_selection_value,
authorizationType: "NONE",
target: "integrations/" + message_integration.ref,
});
// allow other other tables to grant permissions to these lambdas
this.connect_fn = connect_lambda;
this.disconnect_fn = disconnect_lambda;
this.message_fn = message_lambda;
this.connection_table = websocket_table;
this.apigw_role = message_lambda_role;
// deployment
const apigw_wss_deployment = new apigwv2.CfnDeployment(
this,
"apigw-deployment",
{ apiId: websocket_api.ref }
);
// stage
const apigw_wss_stage = new apigwv2.CfnStage(this, "apigw-stage", {
apiId: websocket_api.ref,
autoDeploy: true,
deploymentId: apigw_wss_deployment.ref,
stageName: "v1",
defaultRouteSettings: {
throttlingBurstLimit: 500,
throttlingRateLimit: 1000,
},
});
// custom api domain
const has_necessary_domains =
props.site_domain_name && props.wss_domain_name && props.cert_arn;
// CUSTOM API DOMAIN ------------------------------------------------------------------
if (has_necessary_domains) {
const hosted_zone = route53.HostedZone.fromLookup(this, "hosted-zone", {
domainName: props.site_domain_name!,
});
// custom domain
const apigw_domain_socket = new apigwv2.CfnDomainName(
this,
"apigw-domain-socket",
{
domainName: props.wss_domain_name!,
domainNameConfigurations: [
{
certificateArn: props.cert_arn,
endpointType: apigw.EndpointType.REGIONAL,
},
],
}
);
new apigwv2.CfnApiMapping(this, "apigw-mapping-socket", {
domainName: apigw_domain_socket.ref,
apiId: websocket_api.ref,
stage: apigw_wss_stage.ref,
});
// create the subdomain
new route53.CnameRecord(this, "route-53-socket", {
recordName: props.wss_domain_name,
zone: hosted_zone,
domainName: apigw_domain_socket.attrRegionalDomainName,
});
}
// all routes are dependencies of the deployment
const routes = new cdk.ConcreteDependable();
routes.add(connect_route);
routes.add(disconnect_route);
routes.add(message_route);
// add the dependency
apigw_wss_deployment.node.addDependency(routes);
}
private create_integration_str = (region: string, fn_arn: string): string =>
`arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${fn_arn}/invocations`;
private create_resource_str = (
account_id: string,
region: string,
ref: string
): string => `arn:aws:execute-api:${region}:${account_id}:${ref}/*`;
public grant_read_write = (lambda: lambda.Function) =>
this.connection_table.grantReadWriteData(lambda);
}
And then you use like this:
const websocket_api = new WebSocketConstruct(this, "websocket-api", {
prefix: props?.prefix ?? "",
account_id: this.account,
region: this.region,
cert_arn: props?.cert_arn!,
site_domain_name: props?.domain_name!, // e.g. mydomain.com
wss_domain_name: wss_name, // e.g. socket.mydomain.com
environment: {
MAIN_TABLE_NAME: primary_table.tableName,
QUEUE_URL: room_traffic_queue.queueUrl,
BUCKET_NAME: media_bucket.bucketName,
...secrets,
},
});
Thanks, it actually works. The only changes I did based on your example was to move the ConcreteDependable with the add routes at the bottom. I am not sure if the order made a difference, but the other issues was being impatience. My sub domain took quite some time to propagate and I was keep adding modifications and constantly re-deploy.
Most helpful comment
Is there a timeframe for when this will be worked on? Will this take a couple more months or could there be a preview out within a few weeks? Would love to see this happen!