mirror of
https://github.com/serverless/serverless.git
synced 2026-01-25 15:07:39 +00:00
feat(AWS Websocket): routeResponseSelectionExpression setting (#7233)
Fixes #6130
This commit is contained in:
parent
77baea0347
commit
2d25e678cb
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -209,6 +209,10 @@ module.exports = {
|
||||
return `WebsocketsDeployment${sha.replace(/[^0-9a-z]/gi, '')}`;
|
||||
},
|
||||
|
||||
getWebsocketsRouteResponseLogicalId(route) {
|
||||
return `${this.getWebsocketsRouteLogicalId(route)}Response`;
|
||||
},
|
||||
|
||||
getWebsocketsStageLogicalId() {
|
||||
return 'WebsocketsDeploymentStage';
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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({});
|
||||
});
|
||||
});
|
||||
@ -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 = {
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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(':')) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -18,3 +18,9 @@ functions:
|
||||
route: $disconnect
|
||||
- websocket:
|
||||
route: $default
|
||||
sayHello:
|
||||
handler: core.sayHello
|
||||
events:
|
||||
- websocket:
|
||||
route: hello
|
||||
routeResponseSelectionExpression: $default
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user