diff --git a/invokeLocal/googleInvokeLocal.js b/invokeLocal/googleInvokeLocal.js index bf2c7d7..4fb2747 100644 --- a/invokeLocal/googleInvokeLocal.js +++ b/invokeLocal/googleInvokeLocal.js @@ -29,7 +29,7 @@ class GoogleInvokeLocal { async invokeLocal() { const functionObj = this.serverless.service.getFunction(this.options.function); - this.validateEventsProperty(functionObj, this.options.function, ['event']); // Only event is currently supported + this.validateEventsProperty(functionObj, this.options.function); const runtime = this.provider.getRuntime(functionObj); if (!runtime.startsWith('nodejs')) { diff --git a/invokeLocal/googleInvokeLocal.test.js b/invokeLocal/googleInvokeLocal.test.js index 6e4b373..3d263d9 100644 --- a/invokeLocal/googleInvokeLocal.test.js +++ b/invokeLocal/googleInvokeLocal.test.js @@ -165,9 +165,7 @@ describe('GoogleInvokeLocal', () => { it('should validate the function configuration', async () => { await googleInvokeLocal.invokeLocal(); - expect( - validateEventsPropertyStub.calledOnceWith(functionObj, functionName, ['event']) - ).toEqual(true); + expect(validateEventsPropertyStub.calledOnceWith(functionObj, functionName)).toEqual(true); }); it('should get the runtime', async () => { diff --git a/invokeLocal/lib/httpReqRes.js b/invokeLocal/lib/httpReqRes.js new file mode 100644 index 0000000..2d6b322 --- /dev/null +++ b/invokeLocal/lib/httpReqRes.js @@ -0,0 +1,30 @@ +'use strict'; + +const express = require('express'); +const http = require('http'); +const net = require('net'); + +// The getReqRes method create an express request and an express response +// as they are created in an express server before being passed to the middlewares +// Google use express 4.17.1 to run http cloud function +// https://cloud.google.com/functions/docs/writing/http#http_frameworks +const app = express(); + +module.exports = { + getReqRes() { + const req = new http.IncomingMessage(new net.Socket()); + const expressRequest = Object.assign(req, { app }); + Object.setPrototypeOf(expressRequest, express.request); + + const res = new http.ServerResponse(req); + const expressResponse = Object.assign(res, { app, req: expressRequest }); + Object.setPrototypeOf(expressResponse, express.response); + + expressRequest.res = expressResponse; + + return { + expressRequest, + expressResponse, + }; + }, +}; diff --git a/invokeLocal/lib/nodeJs.js b/invokeLocal/lib/nodeJs.js index b555fe1..d15103b 100644 --- a/invokeLocal/lib/nodeJs.js +++ b/invokeLocal/lib/nodeJs.js @@ -3,6 +3,7 @@ const chalk = require('chalk'); const path = require('path'); const _ = require('lodash'); +const { getReqRes } = require('./httpReqRes'); const tryToRequirePaths = (paths) => { let loaded; @@ -19,10 +20,10 @@ const tryToRequirePaths = (paths) => { return loaded; }; +const jsonContentType = 'application/json'; + module.exports = { async invokeLocalNodeJs(functionObj, event, customContext) { - let hasResponded = false; - // index.js and function.js are the two files supported by default by a cloud-function // TODO add the file pointed by the main key of the package.json const paths = ['index.js', 'function.js'].map((fileName) => @@ -41,27 +42,41 @@ module.exports = { this.addEnvironmentVariablesToProcessEnv(functionObj); - function handleError(err) { - let errorResult; - if (err instanceof Error) { - errorResult = { - errorMessage: err.message, - errorType: err.constructor.name, - stackTrace: err.stack && err.stack.split('\n'), - }; - } else { - errorResult = { - errorMessage: err, - }; - } + const eventType = Object.keys(functionObj.events[0])[0]; - this.serverless.cli.consoleLog(chalk.red(JSON.stringify(errorResult, null, 4))); - process.exitCode = 1; + switch (eventType) { + case 'event': + return this.handleEvent(cloudFunction, event, customContext); + case 'http': + return this.handleHttp(cloudFunction, event, customContext); + default: + throw new Error(`${eventType} is not supported`); } + }, + handleError(err, resolve) { + let errorResult; + if (err instanceof Error) { + errorResult = { + errorMessage: err.message, + errorType: err.constructor.name, + stackTrace: err.stack && err.stack.split('\n'), + }; + } else { + errorResult = { + errorMessage: err, + }; + } + + this.serverless.cli.consoleLog(chalk.red(JSON.stringify(errorResult, null, 4))); + resolve(); + process.exitCode = 1; + }, + handleEvent(cloudFunction, event, customContext) { + let hasResponded = false; function handleResult(result) { if (result instanceof Error) { - handleError.call(this, result); + this.handleError.call(this, result); return; } this.serverless.cli.consoleLog(JSON.stringify(result, null, 4)); @@ -72,12 +87,12 @@ module.exports = { if (!hasResponded) { hasResponded = true; if (err) { - handleError.call(this, err); + this.handleError(err, resolve); } else if (result) { handleResult.call(this, result); } + resolve(); } - resolve(); }; let context = {}; @@ -85,13 +100,55 @@ module.exports = { if (customContext) { context = customContext; } - - const maybeThennable = cloudFunction(event, context, callback); - if (maybeThennable) { - return Promise.resolve(maybeThennable).then(callback.bind(this, null), callback.bind(this)); + try { + const maybeThennable = cloudFunction(event, context, callback); + if (maybeThennable) { + Promise.resolve(maybeThennable).then(callback.bind(this, null), callback.bind(this)); + } + } catch (error) { + this.handleError(error, resolve); } + }); + }, + handleHttp(cloudFunction, event) { + const { expressRequest, expressResponse: response } = getReqRes(); + const request = Object.assign(expressRequest, event); + + return new Promise((resolve) => { + const endCallback = (data) => { + if (data && Buffer.isBuffer(data)) { + data = data.toString(); + } + const headers = response.getHeaders(); + const bodyIsJson = + headers['content-type'] && headers['content-type'].includes(jsonContentType); + if (data && bodyIsJson) { + data = JSON.parse(data); + } + this.serverless.cli.consoleLog( + JSON.stringify( + { + status: response.statusCode, + headers, + body: data, + }, + null, + 4 + ) + ); + resolve(); + }; - return maybeThennable; + Object.assign(response, { end: endCallback }); // Override of the end method which is always called to send the response of the http request + + try { + const maybeThennable = cloudFunction(request, response); + if (maybeThennable) { + Promise.resolve(maybeThennable).catch((error) => this.handleError(error, resolve)); + } + } catch (error) { + this.handleError(error, resolve); + } }); }, diff --git a/invokeLocal/lib/nodeJs.test.js b/invokeLocal/lib/nodeJs.test.js index e1f72f8..5cd86a9 100644 --- a/invokeLocal/lib/nodeJs.test.js +++ b/invokeLocal/lib/nodeJs.test.js @@ -7,14 +7,6 @@ const Serverless = require('../../test/serverless'); jest.spyOn(console, 'log'); describe('invokeLocalNodeJs', () => { - const eventName = 'eventName'; - const contextName = 'contextName'; - const event = { - name: eventName, - }; - const context = { - name: contextName, - }; const myVarValue = 'MY_VAR_VALUE'; let serverless; let googleInvokeLocal; @@ -29,57 +21,160 @@ describe('invokeLocalNodeJs', () => { serverless.cli.consoleLog = jest.fn(); googleInvokeLocal = new GoogleInvokeLocal(serverless, {}); }); - - it('should invoke a sync handler', async () => { - const functionConfig = { - handler: 'syncHandler', + describe('event', () => { + const eventName = 'eventName'; + const contextName = 'contextName'; + const event = { + name: eventName, }; - await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith('SYNC_HANDLER'); - expect(serverless.cli.consoleLog).toHaveBeenCalledWith(`{\n "result": "${eventName}"\n}`); - }); - - it('should handle errors in a sync handler', async () => { - const functionConfig = { - handler: 'syncHandlerWithError', + const context = { + name: contextName, }; - await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith('SYNC_HANDLER'); - expect(serverless.cli.consoleLog).toHaveBeenCalledWith( - expect.stringContaining('"errorMessage": "SYNC_ERROR"') - ); - }); - - it('should invoke an async handler', async () => { - const functionConfig = { - handler: 'asyncHandler', + const baseConfig = { + events: [{ event: {} }], }; - await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith('ASYNC_HANDLER'); - expect(serverless.cli.consoleLog).toHaveBeenCalledWith(`{\n "result": "${contextName}"\n}`); - }); + it('should invoke a sync handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'eventSyncHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('EVENT_SYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith(`{\n "result": "${eventName}"\n}`); + }); - it('should handle errors in an async handler', async () => { - const functionConfig = { - handler: 'asyncHandlerWithError', - }; - await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith('ASYNC_HANDLER'); - expect(serverless.cli.consoleLog).toHaveBeenCalledWith( - expect.stringContaining('"errorMessage": "ASYNC_ERROR"') - ); - }); + it('should handle errors in a sync handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'eventSyncHandlerWithError', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('EVENT_SYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + expect.stringContaining('"errorMessage": "SYNC_ERROR"') + ); + }); - it('should give the environment variables to the handler', async () => { - const functionConfig = { - handler: 'envHandler', + it('should invoke an async handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'eventAsyncHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('EVENT_ASYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + `{\n "result": "${contextName}"\n}` + ); + }); + + it('should handle errors in an async handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'eventAsyncHandlerWithError', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('EVENT_ASYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + expect.stringContaining('"errorMessage": "ASYNC_ERROR"') + ); + }); + + it('should give the environment variables to the handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'eventEnvHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(myVarValue); + }); + }); + describe('http', () => { + const message = 'httpBodyMessage'; + const req = { + body: { message }, + }; + const context = {}; + const baseConfig = { + events: [{ http: '' }], }; - await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, context); - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith(myVarValue); + it('should invoke a sync handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'httpSyncHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, req, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('HTTP_SYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + JSON.stringify( + { + status: 200, + headers: { + 'x-test': 'headerValue', + 'content-type': 'application/json; charset=utf-8', + 'content-length': '37', + 'etag': 'W/"25-F1uWAIMs2TbWZIN1zJauHXahSdU"', + }, + body: { responseMessage: message }, + }, + null, + 4 + ) + ); + }); + + it('should handle errors in a sync handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'httpSyncHandlerWithError', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, req, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('HTTP_SYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + expect.stringContaining('"errorMessage": "SYNC_ERROR"') + ); + }); + + it('should invoke an async handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'httpAsyncHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, req, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('HTTP_ASYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + JSON.stringify({ status: 404, headers: {} }, null, 4) + ); + }); + + it('should handle errors in an async handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'httpAsyncHandlerWithError', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, req, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('HTTP_ASYNC_HANDLER'); + expect(serverless.cli.consoleLog).toHaveBeenCalledWith( + expect.stringContaining('"errorMessage": "ASYNC_ERROR"') + ); + }); + + it('should give the environment variables to the handler', async () => { + const functionConfig = { + ...baseConfig, + handler: 'httpEnvHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, req, context); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(myVarValue); + }); }); }); diff --git a/invokeLocal/lib/testMocks/index.js b/invokeLocal/lib/testMocks/index.js index 61eb892..cc39ca0 100644 --- a/invokeLocal/lib/testMocks/index.js +++ b/invokeLocal/lib/testMocks/index.js @@ -3,30 +3,61 @@ */ 'use strict'; +const wait = () => new Promise((resolve) => setTimeout(resolve, 10)); module.exports = { - syncHandler: (event, context, callback) => { + eventSyncHandler: (event, context, callback) => { // eslint-disable-next-line no-console - console.log('SYNC_HANDLER'); + console.log('EVENT_SYNC_HANDLER'); callback(null, { result: event.name }); }, - syncHandlerWithError: (event, context, callback) => { + eventSyncHandlerWithError: (event, context, callback) => { // eslint-disable-next-line no-console - console.log('SYNC_HANDLER'); + console.log('EVENT_SYNC_HANDLER'); callback('SYNC_ERROR'); }, - asyncHandler: async (event, context) => { + eventAsyncHandler: async (event, context) => { // eslint-disable-next-line no-console - console.log('ASYNC_HANDLER'); + console.log('EVENT_ASYNC_HANDLER'); + await wait(); return { result: context.name }; }, - asyncHandlerWithError: async () => { + eventAsyncHandlerWithError: async () => { // eslint-disable-next-line no-console - console.log('ASYNC_HANDLER'); + console.log('EVENT_ASYNC_HANDLER'); + await wait(); throw new Error('ASYNC_ERROR'); }, - envHandler: async () => { + eventEnvHandler: async () => { // eslint-disable-next-line no-console console.log(process.env.MY_VAR); }, + httpSyncHandler: (req, res) => { + // eslint-disable-next-line no-console + console.log('HTTP_SYNC_HANDLER'); + res.setHeader('x-test', 'headerValue'); + res.send({ responseMessage: req.body.message }); + }, + httpSyncHandlerWithError: () => { + // eslint-disable-next-line no-console + console.log('HTTP_SYNC_HANDLER'); + throw new Error('SYNC_ERROR'); + }, + httpAsyncHandler: async (req, res) => { + // eslint-disable-next-line no-console + console.log('HTTP_ASYNC_HANDLER'); + await wait(); + res.status(404).send(); + }, + httpAsyncHandlerWithError: async () => { + // eslint-disable-next-line no-console + console.log('HTTP_ASYNC_HANDLER'); + await wait(); + throw new Error('ASYNC_ERROR'); + }, + httpEnvHandler: (req, res) => { + // eslint-disable-next-line no-console + console.log(process.env.MY_VAR); + res.send('HTTP_SYNC_BODY'); + }, };