From 2d25e678cb1390d3cfb8899f424ff4638b239ddc Mon Sep 17 00:00:00 2001 From: DougHamil Date: Mon, 16 Mar 2020 03:00:44 -0600 Subject: [PATCH] feat(AWS Websocket): `routeResponseSelectionExpression` setting (#7233) Fixes #6130 --- docs/providers/aws/events/websocket.md | 36 ++++++++++ docs/providers/aws/guide/serverless.yml.md | 1 + lib/plugins/aws/lib/naming.js | 4 ++ lib/plugins/aws/lib/naming.test.js | 8 +++ .../compile/events/websockets/index.js | 3 + .../events/websockets/lib/routeResponses.js | 32 +++++++++ .../websockets/lib/routeResponses.test.js | 72 +++++++++++++++++++ .../compile/events/websockets/lib/routes.js | 5 ++ .../events/websockets/lib/routes.test.js | 43 +++++++++++ .../compile/events/websockets/lib/validate.js | 6 ++ .../events/websockets/lib/validate.test.js | 23 ++++++ .../integration-all/websocket/service/core.js | 6 ++ .../websocket/service/serverless.yml | 6 ++ tests/integration-all/websocket/tests.js | 65 ++++++++++++++--- 14 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.js create mode 100644 lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.test.js diff --git a/docs/providers/aws/events/websocket.md b/docs/providers/aws/events/websocket.md index db101c981..5e44b5a52 100644 --- a/docs/providers/aws/events/websocket.md +++ b/docs/providers/aws/events/websocket.md @@ -45,6 +45,18 @@ functions: route: $disconnect ``` +This code will setup a [RouteResponse](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-route-response.html), enabling you to respond to websocket messages by using the `body` parameter of your handler's callback response: + +```yml +functions: + helloHandler: + handler: handler.helloHandler + events: + - websocket: + route: hello + routeResponseSelectionExpression: $default +``` + ## Routes The API-Gateway provides [4 types of routes](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-overview.html) which relate to the lifecycle of a ws-client: @@ -199,6 +211,30 @@ module.exports.defaultHandler = async (event, context) => { }; ``` +## Respond to a a ws-client message + +To respond to a websocket message from your handler function, [Route Responses](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-route-response.html) can be used. Set the `routeResponseSelectionExpression` option to enable this. This option allows you to respond to a websocket message using the `body` parameter. + +```yml +functions: + sayHelloHandler: + handler: handler.sayHello + events: + - websocket: + route: hello + routeResponseSelectionExpression: $default +``` + +```js +module.exports.helloHandler = async (event, context) => { + const body = JSON.parse(event.body); + return { + statusCode: 200, + body: `Hello, ${body.name}`, + }; +}; +``` + ## Logs Use the following configuration to enable Websocket logs: diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md index 1ff6a0b0d..0f62c297e 100644 --- a/docs/providers/aws/guide/serverless.yml.md +++ b/docs/providers/aws/guide/serverless.yml.md @@ -249,6 +249,7 @@ functions: - user.email - websocket: route: $connect + routeResponseSelectionExpression: $default # optional, setting this enables callbacks on websocket requests for two-way communication authorizer: # name: auth NOTE: you can either use "name" or arn" properties arn: arn:aws:lambda:us-east-1:1234567890:function:auth diff --git a/lib/plugins/aws/lib/naming.js b/lib/plugins/aws/lib/naming.js index 15ed914d1..4194a8eeb 100644 --- a/lib/plugins/aws/lib/naming.js +++ b/lib/plugins/aws/lib/naming.js @@ -209,6 +209,10 @@ module.exports = { return `WebsocketsDeployment${sha.replace(/[^0-9a-z]/gi, '')}`; }, + getWebsocketsRouteResponseLogicalId(route) { + return `${this.getWebsocketsRouteLogicalId(route)}Response`; + }, + getWebsocketsStageLogicalId() { return 'WebsocketsDeploymentStage'; }, diff --git a/lib/plugins/aws/lib/naming.test.js b/lib/plugins/aws/lib/naming.test.js index 647b497f5..e7e5c6b39 100644 --- a/lib/plugins/aws/lib/naming.test.js +++ b/lib/plugins/aws/lib/naming.test.js @@ -225,6 +225,14 @@ describe('#naming()', () => { }); }); + describe('#getWebsocketsRouteResponseLogicalId()', () => { + it('should return the route responses logical id', () => { + expect(sdk.naming.getWebsocketsRouteResponseLogicalId('$connect')).to.equal( + 'SconnectWebsocketsRouteResponse' + ); + }); + }); + describe('#getLambdaWebsocketsPermissionLogicalId()', () => { it('should return the lambda websocket permission logical id', () => { expect(sdk.naming.getLambdaWebsocketsPermissionLogicalId('myFunc')).to.equal( diff --git a/lib/plugins/aws/package/compile/events/websockets/index.js b/lib/plugins/aws/package/compile/events/websockets/index.js index e3e129e4a..ef0a96978 100644 --- a/lib/plugins/aws/package/compile/events/websockets/index.js +++ b/lib/plugins/aws/package/compile/events/websockets/index.js @@ -5,6 +5,7 @@ const BbPromise = require('bluebird'); const validate = require('./lib/validate'); const compileApi = require('./lib/api'); const compileIntegrations = require('./lib/integrations'); +const compileRouteResponses = require('./lib/routeResponses'); const compilePermissions = require('./lib/permissions'); const compileRoutes = require('./lib/routes'); const compileDeployment = require('./lib/deployment'); @@ -22,6 +23,7 @@ class AwsCompileWebsockets { validate, compileApi, compileIntegrations, + compileRouteResponses, compileAuthorizers, compilePermissions, compileRoutes, @@ -40,6 +42,7 @@ class AwsCompileWebsockets { return BbPromise.bind(this) .then(this.compileApi) .then(this.compileIntegrations) + .then(this.compileRouteResponses) .then(this.compileAuthorizers) .then(this.compilePermissions) .then(this.compileRoutes) diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.js b/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.js new file mode 100644 index 000000000..6e079e807 --- /dev/null +++ b/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = { + compileRouteResponses() { + this.validated.events.forEach(event => { + if (!event.routeResponseSelectionExpression) { + return; + } + + const websocketsRouteResponseLogicalId = this.provider.naming.getWebsocketsRouteResponseLogicalId( + event.route + ); + + const websocketsRouteLogicalId = this.provider.naming.getWebsocketsRouteLogicalId( + event.route + ); + + Object.assign(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, { + [websocketsRouteResponseLogicalId]: { + Type: 'AWS::ApiGatewayV2::RouteResponse', + Properties: { + ApiId: this.provider.getApiGatewayWebsocketApiId(), + RouteId: { + Ref: websocketsRouteLogicalId, + }, + RouteResponseKey: '$default', + }, + }, + }); + }); + }, +}; diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.test.js new file mode 100644 index 000000000..107cec8ec --- /dev/null +++ b/lib/plugins/aws/package/compile/events/websockets/lib/routeResponses.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const expect = require('chai').expect; +const AwsCompileWebsocketsEvents = require('../index'); +const Serverless = require('../../../../../../../Serverless'); +const AwsProvider = require('../../../../../provider/awsProvider'); + +describe('#compileRouteResponses()', () => { + let awsCompileWebsocketsEvents; + + 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(); + }); + + it('should create a RouteResponse resource for events with selection expression', () => { + awsCompileWebsocketsEvents.validated = { + events: [ + { + functionName: 'First', + route: '$connect', + routeResponseSelectionExpression: '$default', + }, + ], + }; + + awsCompileWebsocketsEvents.compileRouteResponses(); + + const resources = + awsCompileWebsocketsEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources; + + expect(resources).to.deep.equal({ + SconnectWebsocketsRouteResponse: { + Type: 'AWS::ApiGatewayV2::RouteResponse', + Properties: { + ApiId: { + Ref: 'WebsocketsApi', + }, + RouteId: { + Ref: 'SconnectWebsocketsRoute', + }, + RouteResponseKey: '$default', + }, + }, + }); + }); + + it('should NOT create a RouteResponse for events without selection expression', () => { + awsCompileWebsocketsEvents.validated = { + events: [ + { + functionName: 'First', + route: '$connect', + }, + ], + }; + + awsCompileWebsocketsEvents.compileRouteResponses(); + + const resources = + awsCompileWebsocketsEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources; + + expect(resources).to.deep.equal({}); + }); +}); 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 c88f32f89..f6ac3cef3 100644 --- a/lib/plugins/aws/package/compile/events/websockets/lib/routes.js +++ b/lib/plugins/aws/package/compile/events/websockets/lib/routes.js @@ -28,6 +28,11 @@ module.exports = { }, }; + if (event.routeResponseSelectionExpression) { + routeTemplate[websocketsRouteLogicalId].Properties.RouteResponseSelectionExpression = + event.routeResponseSelectionExpression; + } + if (event.authorizer) { routeTemplate[websocketsRouteLogicalId].Properties.AuthorizationType = 'CUSTOM'; routeTemplate[websocketsRouteLogicalId].Properties.AuthorizerId = { diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/routes.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/routes.test.js index 7a3d1ee3a..828d86083 100644 --- a/lib/plugins/aws/package/compile/events/websockets/lib/routes.test.js +++ b/lib/plugins/aws/package/compile/events/websockets/lib/routes.test.js @@ -84,6 +84,49 @@ describe('#compileRoutes()', () => { }); }); + it('should set routeResponseSelectionExpression when configured', () => { + awsCompileWebsocketsEvents.validated = { + events: [ + { + functionName: 'First', + route: '$connect', + routeResponseSelectionExpression: '$default', + }, + ], + }; + + return awsCompileWebsocketsEvents.compileRoutes().then(() => { + const resources = + awsCompileWebsocketsEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources; + + expect(resources).to.deep.equal({ + SconnectWebsocketsRoute: { + Type: 'AWS::ApiGatewayV2::Route', + Properties: { + ApiId: { + Ref: 'WebsocketsApi', + }, + RouteKey: '$connect', + AuthorizationType: 'NONE', + RouteResponseSelectionExpression: '$default', + Target: { + 'Fn::Join': [ + '/', + [ + 'integrations', + { + Ref: 'FirstWebsocketsIntegration', + }, + ], + ], + }, + }, + }, + }); + }); + }); + it('should set authorizer property for the connect route', () => { awsCompileWebsocketsEvents.validated = { events: [ diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/validate.js b/lib/plugins/aws/package/compile/events/websockets/lib/validate.js index 6eab1856a..4b9af4baa 100644 --- a/lib/plugins/aws/package/compile/events/websockets/lib/validate.js +++ b/lib/plugins/aws/package/compile/events/websockets/lib/validate.js @@ -31,6 +31,12 @@ module.exports = { route: event.websocket.route, }; + // route response + if (event.websocket.routeResponseSelectionExpression) { + websocketObj.routeResponseSelectionExpression = + event.websocket.routeResponseSelectionExpression; + } + // authorizers if (_.isString(event.websocket.authorizer)) { if (event.websocket.authorizer.includes(':')) { diff --git a/lib/plugins/aws/package/compile/events/websockets/lib/validate.test.js b/lib/plugins/aws/package/compile/events/websockets/lib/validate.test.js index 072c14a15..69f15259b 100644 --- a/lib/plugins/aws/package/compile/events/websockets/lib/validate.test.js +++ b/lib/plugins/aws/package/compile/events/websockets/lib/validate.test.js @@ -229,6 +229,29 @@ describe('#validate()', () => { ]); }); + it('should add routeResponse when routeResponseSelectionExpression is configured', () => { + awsCompileWebsocketsEvents.serverless.service.functions = { + first: { + events: [ + { + websocket: { + route: '$connect', + routeResponseSelectionExpression: '$default', + }, + }, + ], + }, + }; + const validated = awsCompileWebsocketsEvents.validate(); + expect(validated.events).to.deep.equal([ + { + functionName: 'first', + route: '$connect', + routeResponseSelectionExpression: '$default', + }, + ]); + }); + it('should ignore non-websocket events', () => { awsCompileWebsocketsEvents.serverless.service.functions = { first: { diff --git a/tests/integration-all/websocket/service/core.js b/tests/integration-all/websocket/service/core.js index 5fb197381..c94bd45e5 100644 --- a/tests/integration-all/websocket/service/core.js +++ b/tests/integration-all/websocket/service/core.js @@ -6,6 +6,12 @@ function minimal(event, context, callback) { return callback(null, { statusCode: 200 }); } +function sayHello(event, context, callback) { + const body = JSON.parse(event.body); + return callback(null, { statusCode: 200, body: `Hello, ${body.name}` }); +} + module.exports = { minimal, + sayHello, }; diff --git a/tests/integration-all/websocket/service/serverless.yml b/tests/integration-all/websocket/service/serverless.yml index 7ed31074b..287fbf853 100644 --- a/tests/integration-all/websocket/service/serverless.yml +++ b/tests/integration-all/websocket/service/serverless.yml @@ -18,3 +18,9 @@ functions: route: $disconnect - websocket: route: $default + sayHello: + handler: core.sayHello + events: + - websocket: + route: hello + routeResponseSelectionExpression: $default diff --git a/tests/integration-all/websocket/tests.js b/tests/integration-all/websocket/tests.js index dc5430ecf..1b9c18042 100644 --- a/tests/integration-all/websocket/tests.js +++ b/tests/integration-all/websocket/tests.js @@ -5,6 +5,7 @@ const WebSocket = require('ws'); const _ = require('lodash'); const { expect } = require('chai'); const awsRequest = require('@serverless/test/aws-request'); +const log = require('log').get('serverless:test'); const { getTmpDirPath, readYamlFile, writeYamlFile } = require('../../utils/fs'); const { confirmCloudWatchLogs, wait } = require('../../utils/misc'); @@ -27,14 +28,14 @@ describe('AWS - API Gateway Websocket Integration Test', function() { before(async () => { tmpDirPath = getTmpDirPath(); - console.info(`Temporary path: ${tmpDirPath}`); + log.debug(`Temporary path: ${tmpDirPath}`); serverlessFilePath = path.join(tmpDirPath, 'serverless.yml'); const serverlessConfig = await createTestService(tmpDirPath, { templateDir: path.join(__dirname, 'service'), }); serviceName = serverlessConfig.service; stackName = `${serviceName}-${stage}`; - console.info(`Deploying "${stackName}" service...`); + log.debug(`Deploying "${stackName}" service...`); return deployService(tmpDirPath); }); @@ -43,13 +44,55 @@ describe('AWS - API Gateway Websocket Integration Test', function() { return removeService(tmpDirPath); }); + async function getWebSocketServerUrl() { + const result = await awsRequest('CloudFormation', 'describeStacks', { StackName: stackName }); + const webSocketServerUrl = _.find(result.Stacks[0].Outputs, { + OutputKey: 'ServiceEndpointWebsocket', + }).OutputValue; + + return webSocketServerUrl; + } + + describe('Two-Way Setup', () => { + it('should expose a websocket route that can reply to a message', async () => { + const webSocketServerUrl = await getWebSocketServerUrl(); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(webSocketServerUrl); + reject = (promiseReject => error => { + promiseReject(error); + try { + ws.close(); + } catch (closeError) { + // safe to ignore + } + })(reject); + + ws.on('error', reject); + ws.on('open', () => { + log.debug("Sending message to 'hello' route"); + ws.send(JSON.stringify({ action: 'hello', name: 'serverless' })); + }); + + ws.on('close', resolve); + + ws.on('message', event => { + try { + log.debug(`Received WebSocket message: ${event}`); + expect(event).to.equal('Hello, serverless'); + } finally { + ws.close(); + } + }); + }); + }); + }); + describe('Minimal Setup', () => { it('should expose an accessible websocket endpoint', async () => { - const result = await awsRequest('CloudFormation', 'describeStacks', { StackName: stackName }); - const webSocketServerUrl = _.find(result.Stacks[0].Outputs, { - OutputKey: 'ServiceEndpointWebsocket', - }).OutputValue; - console.info('WebSocket Server URL', webSocketServerUrl); + const webSocketServerUrl = await getWebSocketServerUrl(); + + log.debug(`WebSocket Server URL ${webSocketServerUrl}`); expect(webSocketServerUrl).to.match(/wss:\/\/.+\.execute-api\..+\.amazonaws\.com.+/); return new Promise((resolve, reject) => { const ws = new WebSocket(webSocketServerUrl); @@ -78,7 +121,7 @@ describe('AWS - API Gateway Websocket Integration Test', function() { ws.on('close', resolve); ws.on('message', event => { - console.info('Unexpected WebSocket message', event); + log.debug('Unexpected WebSocket message', event); reject(new Error('Unexpected message')); }); }); @@ -112,15 +155,15 @@ describe('AWS - API Gateway Websocket Integration Test', function() { // otherwise CF will refuse to delete the deployment because a stage refers to that await deleteStage(websocketApiId, 'dev'); // NOTE: deploying once again to get the stack into the original state - console.info('Redeploying service...'); + log.debug('Redeploying service...'); await deployService(tmpDirPath); - console.info('Deleting external websocket API...'); + log.debug('Deleting external websocket API...'); await deleteApi(websocketApiId); }); it('should add the routes to the referenced API', async () => { const routes = await getRoutes(websocketApiId); - expect(routes.length).to.equal(3); + expect(routes.length).to.equal(4); }); }); });