Repository: serverless/serverless (JavaScript)
Date: 2019-06-19 06:47:18+00:00
Task type: Feature

Issue:
Support shared websocket API
<!--
1. If you have a question and not a feature request please ask first at http://forum.serverless.com
2. Please check if an issue already exists. This feature may have already been requested
3. Check out and follow our Guidelines: https://github.com/serverless/serverless/blob/master/CONTRIBUTING.md
4. Fill out the whole template so we have a good overview on the issue
5. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the Issue
6. Please follow the template, otherwise we'll have to ask you to update it
-->

# This is a Feature Proposal

## Description

We currently support sharing the AWS::ApiGateway::RestApi using:
```yaml
provider:
  apiGateway:
    restApiId: xxxxxxxxx
```
I would like to be able to also support sharing the websocket API (AWS::ApiGatewayV2::Api), e.g. using:
```yaml
provider:
  apiGateway:
    restApiId: xxxxxxxxx
    websocketApiId: yyyyyyyyy
```
Support shared websocket API
<!--
1. If you have a question and not a feature request please ask first at http://forum.serverless.com
2. Please check if an issue already exists. This feature may have already been requested
3. Check out and follow our Guidelines: https://github.com/serverless/serverless/blob/master/CONTRIBUTING.md
4. Fill out the whole template so we have a good overview on the issue
5. Do not remove any section of the template. If something is not applicable leave it empty but leave it in the Issue
6. Please follow the template, otherwise we'll have to ask you to update it
-->

# This is a Feature Proposal

## Description

We currently support sharing the AWS::ApiGateway::RestApi using:
```yaml
provider:
  apiGateway:
    restApiId: xxxxxxxxx
```
I would like to be able to also support sharing the websocket API (AWS::ApiGatewayV2::Api), e.g. using:
```yaml
provider:
  apiGateway:
    restApiId: xxxxxxxxx
    websocketApiId: yyyyyyyyy
```


Issue discussion:

Solution patch:
diff --git a/docs/providers/aws/events/apigateway.md b/docs/providers/aws/events/apigateway.md
index 1499502d643..41bc88ccd68 100644
--- a/docs/providers/aws/events/apigateway.md
+++ b/docs/providers/aws/events/apigateway.md
@@ -1103,6 +1103,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx # REST API resource ID. Default is generated by the framework
     restApiRootResourceId: xxxxxxxxxx # Root resource, represent as / path
+    websocketApiId: xxxxxxxxxx # Websocket API resource ID. Default is generated by the framewok
     description: Some Description # optional - description of deployment history
 
 functions:
@@ -1119,6 +1120,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx
     restApiRootResourceId: xxxxxxxxxx
+    websocketApiId: xxxxxxxxxx
     description: Some Description
 
 functions:
@@ -1136,6 +1138,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx
     restApiRootResourceId: xxxxxxxxxx
+    websocketApiId: xxxxxxxxxx
     description: Some Description
 
 functions:
@@ -1155,6 +1158,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx
     restApiRootResourceId: xxxxxxxxxx
+    websocketApiId: xxxxxxxxxx
     description: Some Description
     restApiResources:
       /posts: xxxxxxxxxx
@@ -1170,6 +1174,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx
     restApiRootResourceId: xxxxxxxxxx
+    websocketApiId: xxxxxxxxxx
     description: Some Description
     restApiResources:
       /posts: xxxxxxxxxx
@@ -1188,6 +1193,7 @@ provider:
   apiGateway:
     restApiId: xxxxxxxxxx
     # restApiRootResourceId: xxxxxxxxxx # Optional
+    websocketApiId: xxxxxxxxxx
     description: Some Description
     restApiResources:
       /posts: xxxxxxxxxx
@@ -1213,7 +1219,7 @@ functions:
 
 ### Easiest and CI/CD friendly example of using shared API Gateway and API Resources.
 
-You can define your API Gateway resource in its own service and export the `restApiId` and `restApiRootResourceId` using cloudformation cross-stack references.
+You can define your API Gateway resource in its own service and export the `restApiId`, `restApiRootResourceId` and `websocketApiId` using cloudformation cross-stack references.
 
 ```yml
 service: my-api
@@ -1231,6 +1237,13 @@ resources:
       Properties:
         Name: MyApiGW
 
+    MyWebsocketApi:
+      Type: AWS::ApiGatewayV2::Api
+      Properties:
+        Name: MyWebsocketApi
+        ProtocolType: WEBSOCKET
+        RouteSelectionExpression: '$request.body.action'
+
   Outputs:
     apiGatewayRestApiId:
       Value:
@@ -1245,9 +1258,16 @@ resources:
           - RootResourceId
       Export:
         Name: MyApiGateway-rootResourceId
+
+    websocketApiId:
+      Value:
+        Ref: MyWebsocketApi
+      Export:
+        Name: MyApiGateway-websocketApiId
+
 ```
 
-This creates API gateway and then exports the `restApiId` and `rootResourceId` values using cloudformation cross stack output.
+This creates API gateway and then exports the `restApiId`, `rootResourceId` and `websocketApiId` values using cloudformation cross stack output.
 We will import this and reference in future services.
 
 ```yml
@@ -1259,6 +1279,8 @@ provider:
       'Fn::ImportValue': MyApiGateway-restApiId
     restApiRootResourceId:
       'Fn::ImportValue': MyApiGateway-rootResourceId
+    websocketApiId:
+      'Fn::ImportValue': MyApiGateway-websocketApiId
 
 functions:
   service-a-functions
@@ -1272,6 +1294,8 @@ provider:
       'Fn::ImportValue': MyApiGateway-restApiId
     restApiRootResourceId:
       'Fn::ImportValue': MyApiGateway-rootResourceId
+    websocketApiId:
+      'Fn::ImportValue': MyApiGateway-websocketApiId
 
 functions:
   service-b-functions
diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md
index c663a375eb7..8456f3402f8 100644
--- a/docs/providers/aws/guide/serverless.yml.md
+++ b/docs/providers/aws/guide/serverless.yml.md
@@ -60,6 +60,7 @@ provider:
     restApiResources: # List of existing resources that were created in the REST API. This is required or the stack will be conflicted
       '/users': xxxxxxxxxx
       '/users/create': xxxxxxxxxx
+    websocketApiId: # Websocket API resource ID. Default is generated by the framewok
     apiKeySourceType: HEADER # Source of API key for usage plan. HEADER or AUTHORIZER.
     minimumCompressionSize: 1024 # Compress response when larger than specified size in bytes (must be between 0 and 10485760)
     description: Some Description # Optional description for the API Gateway stage deployment
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/api.js b/lib/plugins/aws/package/compile/events/websockets/lib/api.js
index f52067a6a67..50212050c23 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/api.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/api.js
@@ -5,6 +5,13 @@ const BbPromise = require('bluebird');
 
 module.exports = {
   compileApi() {
+    const apiGateway = this.serverless.service.provider.apiGateway || {};
+
+    // immediately return if we're using an external websocket API id
+    if (apiGateway.websocketApiId) {
+      return BbPromise.resolve();
+    }
+
     this.websocketsApiLogicalId = this.provider.naming.getWebsocketsApiLogicalId();
 
     const RouteSelectionExpression = this.serverless.service.provider
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.js b/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.js
index 31062df8246..48f413080ce 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.js
@@ -16,9 +16,7 @@ module.exports = {
         [websocketsAuthorizerLogicalId]: {
           Type: 'AWS::ApiGatewayV2::Authorizer',
           Properties: {
-            ApiId: {
-              Ref: this.websocketsApiLogicalId,
-            },
+            ApiId: this.provider.getApiGatewayWebsocketApiId(),
             Name: event.authorizer.name,
             AuthorizerType: 'REQUEST',
             AuthorizerUri: event.authorizer.uri,
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/deployment.js b/lib/plugins/aws/package/compile/events/websockets/lib/deployment.js
index 341d428709b..144c5d8a129 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/deployment.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/deployment.js
@@ -18,9 +18,7 @@ module.exports = {
         Type: 'AWS::ApiGatewayV2::Deployment',
         DependsOn: routeLogicalIds,
         Properties: {
-          ApiId: {
-            Ref: this.websocketsApiLogicalId,
-          },
+          ApiId: this.provider.getApiGatewayWebsocketApiId(),
           Description: this.serverless.service.provider
             .websocketsDescription || 'Serverless Websockets',
         },
@@ -34,7 +32,7 @@ module.exports = {
           'Fn::Join': ['',
             [
               'wss://',
-              { Ref: this.provider.naming.getWebsocketsApiLogicalId() },
+              this.provider.getApiGatewayWebsocketApiId(),
               '.execute-api.',
               { Ref: 'AWS::Region' },
               '.',
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/integrations.js b/lib/plugins/aws/package/compile/events/websockets/lib/integrations.js
index c892ee302e3..1dd44c52047 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/integrations.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/integrations.js
@@ -14,9 +14,7 @@ module.exports = {
         [websocketsIntegrationLogicalId]: {
           Type: 'AWS::ApiGatewayV2::Integration',
           Properties: {
-            ApiId: {
-              Ref: this.websocketsApiLogicalId,
-            },
+            ApiId: this.provider.getApiGatewayWebsocketApiId(),
             IntegrationType: 'AWS_PROXY',
             IntegrationUri: {
               'Fn::Join': ['',
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/permissions.js b/lib/plugins/aws/package/compile/events/websockets/lib/permissions.js
index 4b42f42f839..08833474f08 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/permissions.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/permissions.js
@@ -6,6 +6,7 @@ const BbPromise = require('bluebird');
 module.exports = {
   compilePermissions() {
     this.validated.events.forEach(event => {
+      const websocketApiId = this.provider.getApiGatewayWebsocketApiId();
       const lambdaLogicalId = this.provider.naming.getLambdaLogicalId(event.functionName);
 
       const websocketsPermissionLogicalId = this.provider.naming
@@ -14,7 +15,9 @@ module.exports = {
       _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
         [websocketsPermissionLogicalId]: {
           Type: 'AWS::Lambda::Permission',
-          DependsOn: [this.websocketsApiLogicalId, lambdaLogicalId],
+          DependsOn: (websocketApiId.Ref !== undefined)
+            ? [websocketApiId.Ref, lambdaLogicalId]
+            : [lambdaLogicalId],
           Properties: {
             FunctionName: {
               'Fn::GetAtt': [lambdaLogicalId, 'Arn'],
@@ -32,7 +35,9 @@ module.exports = {
         const authorizerPermissionTemplate = {
           [websocketsAuthorizerPermissionLogicalId]: {
             Type: 'AWS::Lambda::Permission',
-            DependsOn: [this.websocketsApiLogicalId],
+            DependsOn: (websocketApiId.Ref !== undefined)
+              ? [websocketApiId.Ref]
+              : [],
             Properties: {
               Action: 'lambda:InvokeFunction',
               Principal: 'apigateway.amazonaws.com',
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/routes.js b/lib/plugins/aws/package/compile/events/websockets/lib/routes.js
index ef3aa7a865a..2817e624b19 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/routes.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/routes.js
@@ -16,9 +16,7 @@ module.exports = {
         [websocketsRouteLogicalId]: {
           Type: 'AWS::ApiGatewayV2::Route',
           Properties: {
-            ApiId: {
-              Ref: this.websocketsApiLogicalId,
-            },
+            ApiId: this.provider.getApiGatewayWebsocketApiId(),
             RouteKey: event.route,
             AuthorizationType: 'NONE',
             Target: {
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/stage.js b/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
index ae2472565b1..2ee8ad2f0f9 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/stage.js
@@ -23,9 +23,7 @@ module.exports = {
     const stageResource = {
       Type: 'AWS::ApiGatewayV2::Stage',
       Properties: {
-        ApiId: {
-          Ref: this.websocketsApiLogicalId,
-        },
+        ApiId: this.provider.getApiGatewayWebsocketApiId(),
         DeploymentId: {
           Ref: this.websocketsDeploymentLogicalId,
         },
diff --git a/lib/plugins/aws/provider/awsProvider.js b/lib/plugins/aws/provider/awsProvider.js
index bcc309d7aa1..f58ed2a6274 100644
--- a/lib/plugins/aws/provider/awsProvider.js
+++ b/lib/plugins/aws/provider/awsProvider.js
@@ -544,6 +544,18 @@ class AwsProvider {
       }));
   }
 
+  /**
+   * Get API Gateway websocket API ID from serverless config
+   */
+  getApiGatewayWebsocketApiId() {
+    if (this.serverless.service.provider.apiGateway
+      && this.serverless.service.provider.apiGateway.websocketApiId) {
+      return this.serverless.service.provider.apiGateway.websocketApiId;
+    }
+
+    return { Ref: this.naming.getWebsocketsApiLogicalId() };
+  }
+
   getStackResources(next, resourcesParam) {
     let resources = resourcesParam;
     const params = {


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/api.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/api.test.js
index a3ed46155c2..fbe7242f0c6 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/api.test.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/api.test.js
@@ -50,6 +50,18 @@ describe('#compileApi()', () => {
       });
     }));
 
+  it('should ignore API resource creation if there is predefined websocketApi config', () => {
+    awsCompileWebsocketsEvents.serverless.service.provider.apiGateway = {
+      websocketApiId: '5ezys3sght',
+    };
+    return awsCompileWebsocketsEvents.compileApi().then(() => {
+      const resources = awsCompileWebsocketsEvents.serverless.service.provider
+        .compiledCloudFormationTemplate.Resources;
+
+      expect(resources).to.not.have.property('WebsocketsApi');
+    });
+  });
+
   it('should add the websockets policy', () => awsCompileWebsocketsEvents
     .compileApi().then(() => {
       const resources = awsCompileWebsocketsEvents.serverless.service.provider
diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.test.js
index 1925ae6ce2b..5728ae0b682 100644
--- a/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.test.js
+++ b/lib/plugins/aws/package/compile/events/websockets/lib/authorizers.test.js
@@ -8,102 +8,141 @@ const AwsProvider = require('../../../../../provider/awsProvider');
 describe('#compileAuthorizers()', () => {
   let awsCompileWebsocketsEvents;
 
-  beforeEach(() => {
-    const serverless = new Serverless();
-    serverless.setProvider('aws', new AwsProvider(serverless));
-    serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
+  describe('for routes with authorizer definition', () => {
+    beforeEach(() => {
+      const serverless = new Serverless();
+      serverless.setProvider('aws', new AwsProvider(serverless));
+      serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
 
-    awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless);
+      awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless);
 
-    awsCompileWebsocketsEvents.websocketsApiLogicalId
-      = awsCompileWebsocketsEvents.provider.naming.getWebsocketsApiLogicalId();
-  });
+      awsCompileWebsocketsEvents.websocketsApiLogicalId
+        = awsCompileWebsocketsEvents.provider.naming.getWebsocketsApiLogicalId();
 
-  it('should create an authorizer resource for routes with authorizer definition', () => {
-    awsCompileWebsocketsEvents.validated = {
-      events: [
-        {
-          functionName: 'First',
-          route: '$connect',
-          authorizer: {
-            name: 'auth',
-            uri: {
-              'Fn::Join': ['',
-                [
-                  'arn:',
-                  { Ref: 'AWS::Partition' },
-                  ':apigateway:',
-                  { Ref: 'AWS::Region' },
-                  ':lambda:path/2015-03-31/functions/',
-                  { 'Fn::GetAtt': ['AuthLambdaFunction', 'Arn'] },
-                  '/invocations',
+      awsCompileWebsocketsEvents.validated = {
+        events: [
+          {
+            functionName: 'First',
+            route: '$connect',
+            authorizer: {
+              name: 'auth',
+              uri: {
+                'Fn::Join': ['',
+                  [
+                    'arn:',
+                    { Ref: 'AWS::Partition' },
+                    ':apigateway:',
+                    { Ref: 'AWS::Region' },
+                    ':lambda:path/2015-03-31/functions/',
+                    { 'Fn::GetAtt': ['AuthLambdaFunction', 'Arn'] },
+                    '/invocations',
+                  ],
                 ],
-              ],
+              },
+              identitySource: ['route.request.header.Auth'],
             },
-            identitySource: ['route.request.header.Auth'],
           },
-        },
-      ],
-    };
-
-    return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
-      const resources = awsCompileWebsocketsEvents.serverless.service.provider
-        .compiledCloudFormationTemplate.Resources;
-
-      expect(resources).to.deep.equal({
-        AuthWebsocketsAuthorizer: {
-          Type: 'AWS::ApiGatewayV2::Authorizer',
-          Properties: {
-            ApiId: {
-              Ref: 'WebsocketsApi',
-            },
-            Name: 'auth',
-            AuthorizerType: 'REQUEST',
-            AuthorizerUri: {
-              'Fn::Join': [
-                '',
-                [
-                  'arn:',
-                  {
-                    Ref: 'AWS::Partition',
-                  },
-                  ':apigateway:',
-                  {
-                    Ref: 'AWS::Region',
-                  },
-                  ':lambda:path/2015-03-31/functions/',
-                  {
-                    'Fn::GetAtt': [
-                      'AuthLambdaFunction',
-                      'Arn',
-                    ],
-                  },
-                  '/invocations',
+        ],
+      };
+    });
+
+    it('should create an authorizer resource', () => {
+      return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources).to.deep.equal({
+          AuthWebsocketsAuthorizer: {
+            Type: 'AWS::ApiGatewayV2::Authorizer',
+            Properties: {
+              ApiId: {
+                Ref: 'WebsocketsApi',
+              },
+              Name: 'auth',
+              AuthorizerType: 'REQUEST',
+              AuthorizerUri: {
+                'Fn::Join': [
+                  '',
+                  [
+                    'arn:',
+                    {
+                      Ref: 'AWS::Partition',
+                    },
+                    ':apigateway:',
+                    {
+                      Ref: 'AWS::Region',
+                    },
+                    ':lambda:path/2015-03-31/functions/',
+                    {
+                      'Fn::GetAtt': [
+                        'AuthLambdaFunction',
+                        'Arn',
+                      ],
+                    },
+                    '/invocations',
+                  ],
                 ],
-              ],
+              },
+              IdentitySource: ['route.request.header.Auth'],
             },
-            IdentitySource: ['route.request.header.Auth'],
           },
-        },
+        });
+      });
+    });
+
+    it('should use existing Api if there is predefined websocketApi config', () => {
+      awsCompileWebsocketsEvents.serverless.service.provider.apiGateway = {
+        websocketApiId: '5ezys3sght',
+      };
+
+      return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources.AuthWebsocketsAuthorizer.Properties).to.contain({
+          ApiId: '5ezys3sght',
+        });
       });
     });
   });
 
-  it('should NOT create an authorizer resource for routes with not authorizer definition', () => {
-    awsCompileWebsocketsEvents.validated = {
-      events: [
-        {
-          functionName: 'First',
-          route: '$connect',
-        },
-      ],
-    };
-
-    return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
-      const resources = awsCompileWebsocketsEvents.serverless.service.provider
-        .compiledCloudFormationTemplate.Resources;
-
-      expect(resources).to.deep.equal({});
+  describe('for routes without authorizer definition', () => {
+    beforeEach(() => {
+      const serverless = new Serverless();
+      serverless.setProvider('aws', new AwsProvider(serverless));
+      serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
+
+      awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless);
+
+      awsCompileWebsocketsEvents.websocketsApiLogicalId
+        = awsCompileWebsocketsEvents.provider.naming.getWebsocketsApiLogicalId();
+
+      awsCompileWebsocketsEvents.validated = {
+        events: [
+          {
+            functionName: 'First',
+            route: '$connect',
+          },
+        ],
+      };
+    });
+
+    it('should NOT create an authorizer resource for routes with not authorizer definition', () => {
+      awsCompileWebsocketsEvents.validated = {
+        events: [
+          {
+            functionName: 'First',
+            route: '$connect',
+          },
+        ],
+      };
+
+      return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
+        const resources = awsCompileWebsocketsEvents.serverless.service.provider
+          .compiledCloudFormationTemplate.Resources;
+
+        expect(resources).to.deep.equal({});
+      });
     });
   });
 });