Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[apigateway] L2 Construct for Api Gateway V2 (WebSocket) #2872

Assignees
Labels
@aws-cdk/aws-apigatewayv2 Related to Amazon API Gateway v2 effort/large Large work item – several weeks of effort feature-request A feature should be added or improved. p1

Comments

@julienlepine
Copy link
Contributor

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.

@julienlepine julienlepine added the feature-request A feature should be added or improved. label Jun 14, 2019
@NGL321 NGL321 added the needs-triage This issue or PR still needs to be triaged. label Jun 17, 2019
@julienlepine
Copy link
Contributor Author

I have the base of it (used for a demo), will tidy up, and get a PR submitted

@julienlepine
Copy link
Contributor Author

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.

  • Integrations are similar yet in one case they are a construct, in the second they are just parameters in a method
  • Resources don't exist in Api Gateway V2 (but it is the core of Api Gateway v1)

@kenji4569
Copy link

Any update for this?

@BrianG13
Copy link

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!

@julienlepine
Copy link
Contributor Author

julienlepine commented Aug 26, 2019 via email

@BrianG13
Copy link

BrianG13 commented Sep 5, 2019

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.
apigateway
lambda

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?

@julienlepine
Copy link
Contributor Author

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);
    }
}

@julienlepine
Copy link
Contributor Author

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);
    }
}

@julienlepine
Copy link
Contributor Author

I have some time now, anyone wants to take it, or are you happy for me to re-own it?

@eladb
Copy link
Contributor

eladb commented Sep 11, 2019

@nija-at is currently the maintainer of the apigateway module and I think started to think about v2. You guys should sync up.

@SomayaB SomayaB added @aws-cdk/aws-apigateway Related to Amazon API Gateway feature-request A feature should be added or improved. and removed feature-request A feature should be added or improved. needs-triage This issue or PR still needs to be triaged. labels Oct 1, 2019
@SomayaB SomayaB assigned nija-at and unassigned eladb Oct 1, 2019
@spaceemotion
Copy link

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!

@binarythinktank
Copy link

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.

@nija-at
Copy link
Contributor

nija-at commented Nov 21, 2019

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!

@nija-at nija-at changed the title Feature Request (api gateway): build an L2 Construct for Api Gateway V2 (WebSocket) [apigateway] L2 Construct for Api Gateway V2 (WebSocket) Nov 21, 2019
@marcortw
Copy link

marcortw commented Jan 11, 2020

@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.

@bollohz
Copy link

bollohz commented Mar 17, 2020

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)

@SomayaB SomayaB added the in-progress This issue is being actively worked on. label Mar 31, 2020
@bhupendra-bhudia
Copy link

Hi there, is there a working cdk WebSocket with API gateway example I could look at please?

This was referenced Mar 8, 2021
cornerwings pushed a commit to cornerwings/aws-cdk that referenced this issue Mar 8, 2021
feat(apigatewayv2): add support for WebSocket APIs

BREAKING CHANGE: `HttpApiMapping` (and related interfaces for `Attributed` and `Props`) has been renamed to `ApiMapping`
* **apigatewayv2:** `CommonStageOptions` has been renamed to `StageOptions`
* **apigatewayv2:** `HttpStage.fromStageName` has been removed in favour of `HttpStage.fromHttpStageAttributes`
* **apigatewayv2:** `DefaultDomainMappingOptions` has been removed in favour of `DomainMappingOptions`
* **apigatewayv2:** `HttpApiProps.defaultDomainMapping` has been changed from `DefaultDomainMappingOptions` to `DomainMappingOptions`
* **apigatewayv2:** `HttpApi.defaultStage` has been changed from `HttpStage` to `IStage`
* **apigatewayv2:** `IHttpApi.defaultStage` has been removed

closes aws#2872

Some notes:
1. Only Lambda Integration is currently supported
2. No support for `IntegrationResponse` and `RouteResponse`.
3. The `$default` stageName does not seem to work for WebSocket APIs. Therefore modified the API for defaultStage in the API.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
@chtpl
Copy link

chtpl commented Jun 11, 2021

I still struggle to have my lambda triggered by the websockets API Gateway. I added the lambda as integration target but in the lambda the API Gateway does not occur as trigger. When trying to add it manually I can't even select the websockets API gateway

@wakeupmh
Copy link

@chtpl Hey dude, did u resolve your issue? I'm facing the same problem :(

@chtpl
Copy link

chtpl commented Dec 14, 2022

unfortunately not

@sayandcode
Copy link

@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.

Thanks for this! I ran into the same problem in my Cloudformation Template. The Api Gateway docs weren't explicitly clear on this matter either. They should mention common dependencies on the page of the resource, instead of hoping that we'll read the whole docs end-to-end (and stumble upon the relevant rule) before starting to implement stuff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment