feat(AWS Websocket): routeResponseSelectionExpression setting (#7233)

Fixes #6130
This commit is contained in:
DougHamil 2020-03-16 03:00:44 -06:00 committed by GitHub
parent 77baea0347
commit 2d25e678cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 299 additions and 11 deletions

View File

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

View File

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

View File

@ -209,6 +209,10 @@ module.exports = {
return `WebsocketsDeployment${sha.replace(/[^0-9a-z]/gi, '')}`;
},
getWebsocketsRouteResponseLogicalId(route) {
return `${this.getWebsocketsRouteLogicalId(route)}Response`;
},
getWebsocketsStageLogicalId() {
return 'WebsocketsDeploymentStage';
},

View File

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

View File

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

View File

@ -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',
},
},
});
});
},
};

View File

@ -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({});
});
});

View File

@ -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 = {

View File

@ -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: [

View File

@ -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(':')) {

View File

@ -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: {

View File

@ -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,
};

View File

@ -18,3 +18,9 @@ functions:
route: $disconnect
- websocket:
route: $default
sayHello:
handler: core.sayHello
events:
- websocket:
route: hello
routeResponseSelectionExpression: $default

View File

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