diff --git a/docs/events/apigateway.md b/docs/events/apigateway.md index d84ed239c3..642ee40ec0 100644 --- a/docs/events/apigateway.md +++ b/docs/events/apigateway.md @@ -609,6 +609,23 @@ functions: async: true # default is false ``` +### Enabling response streaming + +Enable response streaming for proxy integrations by setting `response.transferMode` on your `http` event: + +```yml +functions: + stream: + handler: handler.stream + events: + - http: + path: stream + method: get + # Proxy integrations only (AWS_PROXY / HTTP_PROXY) + response: + transferMode: STREAM # defaults to BUFFERED +``` + ### Catching Exceptions In Your Lambda Function In case an exception is thrown in your lambda function AWS will send an error message with `Process exited before completing request`. This will be caught by the regular expression for the 500 HTTP status and the 500 status will be returned. diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index a4b9f01b3e..fb68019a10 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -826,6 +826,10 @@ functions: application/json: '{ "httpMethod" : "$context.httpMethod" }' # Optional define pass through behavior when content-type does not match any of the specified mapping templates passThrough: NEVER + # Enable streaming responses by setting transferMode to STREAM (default is BUFFERED) + response: + transferMode: STREAM + ``` ### Websocket API diff --git a/lib/plugins/aws/package/compile/events/api-gateway/index.js b/lib/plugins/aws/package/compile/events/api-gateway/index.js index 09e3fd8bfa..a94e05dbae 100644 --- a/lib/plugins/aws/package/compile/events/api-gateway/index.js +++ b/lib/plugins/aws/package/compile/events/api-gateway/index.js @@ -140,6 +140,9 @@ const responseSchema = { additionalProperties: { type: 'string' }, }, template: { type: 'string' }, + transferMode: { + anyOf: ['BUFFERED', 'STREAM'].map(caseInsensitive), + }, statusCodes: { type: 'object', propertyNames: { diff --git a/lib/plugins/aws/package/compile/events/api-gateway/lib/method/integration.js b/lib/plugins/aws/package/compile/events/api-gateway/lib/method/integration.js index 972376f2fa..83567cb0ab 100644 --- a/lib/plugins/aws/package/compile/events/api-gateway/lib/method/integration.js +++ b/lib/plugins/aws/package/compile/events/api-gateway/lib/method/integration.js @@ -70,6 +70,11 @@ module.exports = { // * `HTTP_PROXY` for integrating with the HTTP proxy integration, or // * `AWS_PROXY` for integrating with the Lambda proxy integration type (the default) if (type === 'AWS' || type === 'AWS_PROXY') { + const responseTransferMode = http.response && http.response.transferMode; + const isStreaming = responseTransferMode && responseTransferMode.toUpperCase() === 'STREAM' && type === 'AWS_PROXY'; + const lambdaPathVersion = isStreaming ? '2021-11-15' : '2015-03-31'; + const invocationPath = isStreaming ? '/response-streaming-invocations' : '/invocations'; + Object.assign(integration, { Uri: { 'Fn::Join': [ @@ -79,11 +84,11 @@ module.exports = { { Ref: 'AWS::Partition' }, ':apigateway:', { Ref: 'AWS::Region' }, - ':lambda:path/2015-03-31/functions/', + `:lambda:path/${lambdaPathVersion}/functions/`, ...[], { 'Fn::GetAtt': [lambdaLogicalId, 'Arn'] }, ...(lambdaAliasName ? [':', lambdaAliasName] : []), - '/invocations', + invocationPath, ], ], }, @@ -124,6 +129,13 @@ module.exports = { }); } + const responseTransferMode = http.response &&http.response.transferMode; + const supportsResponseTransferMode = + !!responseTransferMode && (type === 'AWS_PROXY' || type === 'HTTP_PROXY'); + if (supportsResponseTransferMode) { + Object.assign(integration, { ResponseTransferMode: responseTransferMode }); + } + return { Properties: { Integration: integration, diff --git a/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.js b/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.js index c29af980b2..d8d3e2b513 100644 --- a/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.js +++ b/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.js @@ -111,6 +111,36 @@ module.exports = { http.integration = this.getIntegration(http); + // Validate transferMode if provided + if (http.response && http.response.transferMode) { + const validTransferModes = ['BUFFERED', 'STREAM']; + const transferMode = http.response.transferMode.toUpperCase(); + + if (!validTransferModes.includes(transferMode)) { + throw new ServerlessError( + [ + `Invalid transferMode "${http.response.transferMode}" in function "${functionName}".`, + `Valid values are: ${validTransferModes.join(', ')}.`, + ].join(' '), + 'API_GATEWAY_INVALID_TRANSFER_MODE' + ); + } + + // transferMode is only supported for AWS_PROXY and HTTP_PROXY integrations + if (http.integration !== 'AWS_PROXY' && http.integration !== 'HTTP_PROXY') { + throw new ServerlessError( + [ + `transferMode can only be used with AWS_PROXY or HTTP_PROXY integrations.`, + `Function "${functionName}" is using ${http.integration} integration.`, + ].join(' '), + 'API_GATEWAY_TRANSFER_MODE_UNSUPPORTED_INTEGRATION' + ); + } + + // Normalize transferMode to uppercase + http.response.transferMode = transferMode; + } + if (http.integration === 'HTTP' || http.integration === 'HTTP_PROXY') { if (!http.request || !http.request.uri) { const errorMessage = [ @@ -171,15 +201,27 @@ module.exports = { } } if (http.response) { - log.warning( - [ - `You're using the ${http.integration} in combination with response`, - ` configuration in your function "${functionName}".`, - ' Serverless will remove this configuration automatically before deployment.', - ].join('') - ); + const transferMode = http.response.transferMode; + const hasOtherResponseConfig = + Object.keys(http.response).some((key) => key !== 'transferMode'); + + if (hasOtherResponseConfig) { + log.warning( + [ + `You're using the ${http.integration} in combination with response`, + ` configuration in your function "${functionName}".`, + ' Serverless will remove this configuration automatically before deployment.', + transferMode ? ' (transferMode will be preserved)' : '', + ].join('') + ); + } - delete http.response; + // Preserve transferMode if present, otherwise delete the entire response object + if (transferMode) { + http.response = { transferMode }; + } else { + delete http.response; + } } } diff --git a/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/method/index.test.js b/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/method/index.test.js index 61b09f11dc..e9644b531b 100644 --- a/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/method/index.test.js +++ b/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/method/index.test.js @@ -1051,6 +1051,133 @@ describe('#compileMethods()', () => { }); }); + it('should use streaming lambda URI when transferMode is STREAM with AWS_PROXY', () => { + awsCompileApigEvents.validated.events = [ + { + functionName: 'First', + http: { + path: 'users/create', + method: 'post', + integration: 'AWS_PROXY', + response: { + transferMode: 'STREAM', + }, + }, + }, + ]; + awsCompileApigEvents.compileMethods(); + const integration = + awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate.Resources + .ApiGatewayMethodUsersCreatePost.Properties.Integration; + expect(integration.Uri).to.deep.equal({ + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':apigateway:', + { Ref: 'AWS::Region' }, + ':lambda:path/2021-11-15/functions/', + { 'Fn::GetAtt': ['FirstLambdaFunction', 'Arn'] }, + '/response-streaming-invocations', + ], + ], + }); + expect(integration.ResponseTransferMode).to.equal('STREAM'); + }); + + it('should use default lambda URI when transferMode is BUFFERED with AWS_PROXY', () => { + awsCompileApigEvents.validated.events = [ + { + functionName: 'First', + http: { + path: 'users/create', + method: 'post', + integration: 'AWS_PROXY', + response: { + transferMode: 'BUFFERED', + }, + }, + }, + ]; + awsCompileApigEvents.compileMethods(); + const integration = + awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate.Resources + .ApiGatewayMethodUsersCreatePost.Properties.Integration; + expect(integration.Uri).to.deep.equal({ + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':apigateway:', + { Ref: 'AWS::Region' }, + ':lambda:path/2015-03-31/functions/', + { 'Fn::GetAtt': ['FirstLambdaFunction', 'Arn'] }, + '/invocations', + ], + ], + }); + expect(integration.ResponseTransferMode).to.equal('BUFFERED'); + }); + + it('should use default lambda URI when no transferMode is set', () => { + awsCompileApigEvents.validated.events = [ + { + functionName: 'First', + http: { + path: 'users/create', + method: 'post', + integration: 'AWS_PROXY', + }, + }, + ]; + awsCompileApigEvents.compileMethods(); + const integration = + awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate.Resources + .ApiGatewayMethodUsersCreatePost.Properties.Integration; + expect(integration.Uri).to.deep.equal({ + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':apigateway:', + { Ref: 'AWS::Region' }, + ':lambda:path/2015-03-31/functions/', + { 'Fn::GetAtt': ['FirstLambdaFunction', 'Arn'] }, + '/invocations', + ], + ], + }); + expect(integration).to.not.have.property('ResponseTransferMode'); + }); + + it('should not set ResponseTransferMode for non-proxy integrations', () => { + awsCompileApigEvents.validated.events = [ + { + functionName: 'First', + http: { + path: 'users/create', + method: 'post', + integration: 'AWS', + response: { + statusCodes: { + 200: { + pattern: '', + }, + }, + }, + }, + }, + ]; + awsCompileApigEvents.compileMethods(); + const integration = + awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate.Resources + .ApiGatewayMethodUsersCreatePost.Properties.Integration; + expect(integration).to.not.have.property('ResponseTransferMode'); + }); + it('should add CORS origins to method only when CORS is enabled', () => { awsCompileApigEvents.validated.events = [ { diff --git a/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.test.js b/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.test.js index 40cc65b799..54fccc1d25 100644 --- a/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.test.js +++ b/test/unit/lib/plugins/aws/package/compile/events/api-gateway/lib/validate.test.js @@ -1100,6 +1100,178 @@ describe('#validate()', () => { expect(() => awsCompileApigEvents.validate()).to.throw(Error); }); + it('should accept transferMode STREAM with AWS_PROXY integration', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda-proxy', + response: { + transferMode: 'STREAM', + }, + }, + }, + ], + }, + }; + + const validated = awsCompileApigEvents.validate(); + expect(validated.events).to.be.an('Array').with.length(1); + expect(validated.events[0].http.response.transferMode).to.equal('STREAM'); + }); + + it('should accept transferMode BUFFERED with AWS_PROXY integration', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda-proxy', + response: { + transferMode: 'BUFFERED', + }, + }, + }, + ], + }, + }; + + const validated = awsCompileApigEvents.validate(); + expect(validated.events).to.be.an('Array').with.length(1); + expect(validated.events[0].http.response.transferMode).to.equal('BUFFERED'); + }); + + it('should normalize transferMode to uppercase', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda-proxy', + response: { + transferMode: 'stream', + }, + }, + }, + ], + }, + }; + + const validated = awsCompileApigEvents.validate(); + expect(validated.events).to.be.an('Array').with.length(1); + expect(validated.events[0].http.response.transferMode).to.equal('STREAM'); + }); + + it('should throw if transferMode is invalid', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda-proxy', + response: { + transferMode: 'INVALID', + }, + }, + }, + ], + }, + }; + + expect(() => awsCompileApigEvents.validate()).to.throw( + ServerlessError, + 'Invalid transferMode "INVALID"' + ); + }); + + it('should throw if transferMode is used with non-proxy integration', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda', + response: { + transferMode: 'STREAM', + }, + }, + }, + ], + }, + }; + + expect(() => awsCompileApigEvents.validate()).to.throw( + ServerlessError, + 'transferMode can only be used with AWS_PROXY or HTTP_PROXY integrations' + ); + }); + + it('should accept transferMode with HTTP_PROXY integration', () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'HTTP_PROXY', + request: { + uri: 'https://example.com', + }, + response: { + transferMode: 'STREAM', + }, + }, + }, + ], + }, + }; + + const validated = awsCompileApigEvents.validate(); + expect(validated.events).to.be.an('Array').with.length(1); + expect(validated.events[0].http.response.transferMode).to.equal('STREAM'); + }); + + it('should preserve transferMode but remove other response config with LAMBDA-PROXY', async () => { + awsCompileApigEvents.serverless.service.functions = { + first: { + events: [ + { + http: { + method: 'GET', + path: 'users/list', + integration: 'lambda-proxy', + response: { + transferMode: 'STREAM', + headers: { + 'Content-Type': 'text/html', + }, + }, + }, + }, + ], + }, + }; + return serverless.init().then(() => { + sinon.stub(serverless.cli, 'log'); + + const validated = awsCompileApigEvents.validate(); + expect(validated.events).to.be.an('Array').with.length(1); + expect(validated.events[0].http.response).to.deep.equal({ transferMode: 'STREAM' }); + }); + }); + it('should support MOCK integration', () => { awsCompileApigEvents.serverless.service.functions = { first: { diff --git a/types/index.d.ts b/types/index.d.ts index 2f913daf8c..0a859c73dd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -303,6 +303,7 @@ export interface AWS { }; response?: { contentHandling?: "CONVERT_TO_BINARY" | "CONVERT_TO_TEXT"; + transferMode?: "BUFFERED" | "STREAM"; headers?: { [k: string]: string; };