diff --git a/.travis.yml b/.travis.yml index fee6ed3..6bcd73a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -101,9 +101,6 @@ jobs: - name: 'Unit Tests - Node.js v10' node_js: 10 - - name: 'Unit Tests - Node.js v8' - node_js: 8 - - stage: Tag on Release name: 'Tag on Release' node_js: 12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fa0f6..9eaa2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,22 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [4.1.0](https://github.com/serverless/serverless-google-cloudfunctions/compare/v4.0.0...v4.1.0) (2021-06-07) + +### Features + +- Add support for invoke local ([#258](https://github.com/serverless/serverless-google-cloudfunctions/issues/258)) ([9e07fed](https://github.com/serverless/serverless-google-cloudfunctions/commit/9e07fedf8049836a45b038ddd2b972526c8aee6a)) ([Corentin Doue](https://github.com/CorentinDoue)) + +### Bug Fixes + +- CLI option `count` type deprecation warning ([#257](https://github.com/serverless/serverless-google-cloudfunctions/issues/257)) ([8b97064](https://github.com/serverless/serverless-google-cloudfunctions/commit/8b970648f08ee39c1e8d60a373c2c1798c8cde3f)) ([Michael Haglund](https://github.com/hagmic)) + ## [4.0.0](https://github.com/serverless/serverless-google-cloudfunctions/compare/v3.1.1...v4.0.0) (2021-04-12) ### ⚠ BREAKING CHANGES - Node.js version 10 or later is required (dropped support for v6 and v8) +- Default runtime has been changed to `nodejs10` ### Features diff --git a/index.js b/index.js index aa0c9a6..b3295be 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const GooglePackage = require('./package/googlePackage'); const GoogleDeploy = require('./deploy/googleDeploy'); const GoogleRemove = require('./remove/googleRemove'); const GoogleInvoke = require('./invoke/googleInvoke'); +const GoogleInvokeLocal = require('./invokeLocal/googleInvokeLocal'); const GoogleLogs = require('./logs/googleLogs'); const GoogleInfo = require('./info/googleInfo'); @@ -24,6 +25,7 @@ class GoogleIndex { this.serverless.pluginManager.addPlugin(GoogleDeploy); this.serverless.pluginManager.addPlugin(GoogleRemove); this.serverless.pluginManager.addPlugin(GoogleInvoke); + this.serverless.pluginManager.addPlugin(GoogleInvokeLocal); this.serverless.pluginManager.addPlugin(GoogleLogs); this.serverless.pluginManager.addPlugin(GoogleInfo); } diff --git a/invokeLocal/googleInvokeLocal.js b/invokeLocal/googleInvokeLocal.js new file mode 100644 index 0000000..bf2c7d7 --- /dev/null +++ b/invokeLocal/googleInvokeLocal.js @@ -0,0 +1,42 @@ +'use strict'; + +const validate = require('../shared/validate'); +const setDefaults = require('../shared/utils'); +const getDataAndContext = require('./lib/getDataAndContext'); +const nodeJs = require('./lib/nodeJs'); + +class GoogleInvokeLocal { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options; + + this.provider = this.serverless.getProvider('google'); + + Object.assign(this, validate, setDefaults, getDataAndContext, nodeJs); + + this.hooks = { + 'initialize': () => { + this.options = this.serverless.processedInput.options; + }, + 'before:invoke:local:invoke': async () => { + await this.validate(); + await this.setDefaults(); + await this.getDataAndContext(); + }, + 'invoke:local:invoke': async () => this.invokeLocal(), + }; + } + + async invokeLocal() { + const functionObj = this.serverless.service.getFunction(this.options.function); + this.validateEventsProperty(functionObj, this.options.function, ['event']); // Only event is currently supported + + const runtime = this.provider.getRuntime(functionObj); + if (!runtime.startsWith('nodejs')) { + throw new Error(`Local invocation with runtime ${runtime} is not supported`); + } + return this.invokeLocalNodeJs(functionObj, this.options.data, this.options.context); + } +} + +module.exports = GoogleInvokeLocal; diff --git a/invokeLocal/googleInvokeLocal.test.js b/invokeLocal/googleInvokeLocal.test.js new file mode 100644 index 0000000..6e4b373 --- /dev/null +++ b/invokeLocal/googleInvokeLocal.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const sinon = require('sinon'); + +const GoogleProvider = require('../provider/googleProvider'); +const GoogleInvokeLocal = require('./googleInvokeLocal'); +const Serverless = require('../test/serverless'); + +describe('GoogleInvokeLocal', () => { + let serverless; + const functionName = 'myFunction'; + const rawOptions = { + f: functionName, + }; + const processedOptions = { + function: functionName, + }; + let googleInvokeLocal; + + beforeAll(() => { + serverless = new Serverless(); + serverless.setProvider('google', new GoogleProvider(serverless)); + googleInvokeLocal = new GoogleInvokeLocal(serverless, rawOptions); + serverless.processedInput.options = processedOptions; + }); + + describe('#constructor()', () => { + it('should set the serverless instance', () => { + expect(googleInvokeLocal.serverless).toEqual(serverless); + }); + + it('should set the raw options if provided', () => { + expect(googleInvokeLocal.options).toEqual(rawOptions); + }); + + it('should make the provider accessible', () => { + expect(googleInvokeLocal.provider).toBeInstanceOf(GoogleProvider); + }); + + it.each` + method + ${'validate'} + ${'setDefaults'} + ${'getDataAndContext'} + ${'invokeLocalNodeJs'} + ${'loadFileInOption'} + ${'validateEventsProperty'} + ${'addEnvironmentVariablesToProcessEnv'} + `('should declare $method method', ({ method }) => { + expect(googleInvokeLocal[method]).toBeDefined(); + }); + + describe('hooks', () => { + let validateStub; + let setDefaultsStub; + let getDataAndContextStub; + let invokeLocalStub; + + beforeAll(() => { + validateStub = sinon.stub(googleInvokeLocal, 'validate').resolves(); + setDefaultsStub = sinon.stub(googleInvokeLocal, 'setDefaults').resolves(); + getDataAndContextStub = sinon.stub(googleInvokeLocal, 'getDataAndContext').resolves(); + invokeLocalStub = sinon.stub(googleInvokeLocal, 'invokeLocal').resolves(); + }); + + afterEach(() => { + googleInvokeLocal.validate.resetHistory(); + googleInvokeLocal.setDefaults.resetHistory(); + googleInvokeLocal.getDataAndContext.resetHistory(); + googleInvokeLocal.invokeLocal.resetHistory(); + }); + + afterAll(() => { + googleInvokeLocal.validate.restore(); + googleInvokeLocal.setDefaults.restore(); + googleInvokeLocal.getDataAndContext.restore(); + googleInvokeLocal.invokeLocal.restore(); + }); + + it.each` + hook + ${'initialize'} + ${'before:invoke:local:invoke'} + ${'invoke:local:invoke'} + `('should declare $hook hook', ({ hook }) => { + expect(googleInvokeLocal.hooks[hook]).toBeDefined(); + }); + + describe('initialize hook', () => { + it('should override raw options with processed options', () => { + googleInvokeLocal.hooks.initialize(); + expect(googleInvokeLocal.options).toEqual(processedOptions); + }); + }); + + describe('before:invoke:local:invoke hook', () => { + it('should validate the configuration', async () => { + await googleInvokeLocal.hooks['before:invoke:local:invoke'](); + expect(validateStub.calledOnce).toEqual(true); + }); + + it('should set the defaults values', async () => { + await googleInvokeLocal.hooks['before:invoke:local:invoke'](); + expect(setDefaultsStub.calledOnce).toEqual(true); + }); + + it('should resolve the data and the context of the invocation', async () => { + await googleInvokeLocal.hooks['before:invoke:local:invoke'](); + expect(getDataAndContextStub.calledOnce).toEqual(true); + }); + }); + + describe('invoke:local:invoke hook', () => { + it('should invoke the function locally', () => { + googleInvokeLocal.hooks['invoke:local:invoke'](); + expect(invokeLocalStub.calledOnce).toEqual(true); + }); + }); + }); + }); + + describe('#invokeLocal()', () => { + const functionObj = Symbol('functionObj'); + const data = Symbol('data'); + const context = Symbol('context'); + const runtime = 'nodejs14'; + let getFunctionStub; + let validateEventsPropertyStub; + let getRuntimeStub; + let invokeLocalNodeJsStub; + + beforeAll(() => { + googleInvokeLocal.options = { + ...processedOptions, // invokeLocal is called after the initialize hook which override the options + data, // data and context are populated by getDataAndContext in before:invoke:local:invoke hook + context, + }; + getFunctionStub = sinon.stub(serverless.service, 'getFunction').returns(functionObj); + validateEventsPropertyStub = sinon + .stub(googleInvokeLocal, 'validateEventsProperty') + .returns(); + getRuntimeStub = sinon.stub(googleInvokeLocal.provider, 'getRuntime').returns(runtime); + + invokeLocalNodeJsStub = sinon.stub(googleInvokeLocal, 'invokeLocalNodeJs').resolves(); + }); + + afterEach(() => { + serverless.service.getFunction.resetHistory(); + googleInvokeLocal.validateEventsProperty.resetHistory(); + googleInvokeLocal.provider.getRuntime.resetHistory(); + googleInvokeLocal.invokeLocalNodeJs.resetHistory(); + }); + + afterAll(() => { + serverless.service.getFunction.restore(); + googleInvokeLocal.validateEventsProperty.restore(); + googleInvokeLocal.provider.getRuntime.restore(); + googleInvokeLocal.invokeLocalNodeJs.restore(); + }); + + it('should get the function configuration', async () => { + await googleInvokeLocal.invokeLocal(); + expect(getFunctionStub.calledOnceWith(functionName)).toEqual(true); + }); + + it('should validate the function configuration', async () => { + await googleInvokeLocal.invokeLocal(); + expect( + validateEventsPropertyStub.calledOnceWith(functionObj, functionName, ['event']) + ).toEqual(true); + }); + + it('should get the runtime', async () => { + await googleInvokeLocal.invokeLocal(); + expect(getRuntimeStub.calledOnceWith(functionObj)).toEqual(true); + }); + + it('should invoke locally the function with node js', async () => { + await googleInvokeLocal.invokeLocal(); + expect(invokeLocalNodeJsStub.calledOnceWith(functionObj, data, context)).toEqual(true); + }); + + it('should throw if the runtime is not node js', async () => { + getRuntimeStub.returns('python3'); + await expect(googleInvokeLocal.invokeLocal()).rejects.toThrow( + 'Local invocation with runtime python3 is not supported' + ); + }); + }); +}); diff --git a/invokeLocal/lib/getDataAndContext.js b/invokeLocal/lib/getDataAndContext.js new file mode 100644 index 0000000..0928c54 --- /dev/null +++ b/invokeLocal/lib/getDataAndContext.js @@ -0,0 +1,59 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const stdin = require('get-stdin'); + +module.exports = { + async loadFileInOption(filePath, optionKey) { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(this.serverless.serviceDir, filePath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`The file you provided does not exist: ${absolutePath}`); + } + if (absolutePath.endsWith('.js')) { + // to support js - export as an input data + this.options[optionKey] = require(absolutePath); + return; + } + this.options[optionKey] = await this.serverless.utils.readFile(absolutePath); + }, + + async getDataAndContext() { + // unless asked to preserve raw input, attempt to parse any provided objects + if (!this.options.raw) { + if (this.options.data) { + try { + this.options.data = JSON.parse(this.options.data); + } catch (exception) { + // do nothing if it's a simple string or object already + } + } + if (this.options.context) { + try { + this.options.context = JSON.parse(this.options.context); + } catch (exception) { + // do nothing if it's a simple string or object already + } + } + } + + if (!this.options.data) { + if (this.options.path) { + await this.loadFileInOption(this.options.path, 'data'); + } else { + try { + this.options.data = await stdin(); + } catch (e) { + // continue if no stdin was provided + } + } + } + + if (!this.options.context && this.options.contextPath) { + await this.loadFileInOption(this.options.contextPath, 'context'); + } + }, +}; diff --git a/invokeLocal/lib/getDataAndContext.test.js b/invokeLocal/lib/getDataAndContext.test.js new file mode 100644 index 0000000..084a33c --- /dev/null +++ b/invokeLocal/lib/getDataAndContext.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const sinon = require('sinon'); + +const GoogleProvider = require('../../provider/googleProvider'); +const GoogleInvokeLocal = require('../googleInvokeLocal'); +const Serverless = require('../../test/serverless'); + +jest.mock('get-stdin'); + +describe('getDataAndContext', () => { + let serverless; + let googleInvokeLocal; + let loadFileInOptionStub; + + beforeEach(() => { + serverless = new Serverless(); + serverless.setProvider('google', new GoogleProvider(serverless)); + googleInvokeLocal = new GoogleInvokeLocal(serverless, {}); + loadFileInOptionStub = sinon.stub(googleInvokeLocal, 'loadFileInOption').resolves(); + }); + + afterEach(() => { + googleInvokeLocal.loadFileInOption.restore(); + }); + + describe.each` + key | pathKey + ${'data'} | ${'path'} + ${'context'} | ${'contextPath'} + `('$key', ({ key, pathKey }) => { + it('should keep the raw value if the value exist and there is the raw option', async () => { + const rawValue = Symbol('rawValue'); + googleInvokeLocal.options[key] = rawValue; + googleInvokeLocal.options.raw = true; + await googleInvokeLocal.getDataAndContext(); + expect(googleInvokeLocal.options[key]).toEqual(rawValue); + }); + + it('should keep the raw value if the value exist and is not a valid JSON', async () => { + const rawValue = 'rawValue'; + googleInvokeLocal.options[key] = rawValue; + await googleInvokeLocal.getDataAndContext(); + expect(googleInvokeLocal.options[key]).toEqual(rawValue); + }); + + it('should parse the raw value if the value exist and is a stringified JSON', async () => { + googleInvokeLocal.options[key] = '{"attribute":"value"}'; + await googleInvokeLocal.getDataAndContext(); + expect(googleInvokeLocal.options[key]).toEqual({ attribute: 'value' }); + }); + + it('should load the file from the provided path if it exists', async () => { + const path = 'path'; + googleInvokeLocal.options[pathKey] = path; + await googleInvokeLocal.getDataAndContext(); + expect(loadFileInOptionStub.calledOnceWith(path, key)).toEqual(true); + }); + + it('should not load the file from the provided path if the key already exists', async () => { + const rawValue = Symbol('rawValue'); + googleInvokeLocal.options[key] = rawValue; + googleInvokeLocal.options[pathKey] = 'path'; + + await googleInvokeLocal.getDataAndContext(); + + expect(loadFileInOptionStub.notCalled).toEqual(true); + expect(googleInvokeLocal.options[key]).toEqual(rawValue); + }); + }); +}); diff --git a/invokeLocal/lib/nodeJs.js b/invokeLocal/lib/nodeJs.js new file mode 100644 index 0000000..b555fe1 --- /dev/null +++ b/invokeLocal/lib/nodeJs.js @@ -0,0 +1,102 @@ +'use strict'; + +const chalk = require('chalk'); +const path = require('path'); +const _ = require('lodash'); + +const tryToRequirePaths = (paths) => { + let loaded; + paths.forEach((pathToLoad) => { + if (loaded) { + return; + } + try { + loaded = require(pathToLoad); + } catch (e) { + // pass + } + }); + return loaded; +}; + +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) => + path.join(this.serverless.serviceDir, fileName) + ); + + const handlerContainer = tryToRequirePaths(paths); + if (!handlerContainer) { + throw new Error(`Failed to require one of the files ${paths.join(', ')}`); + } + + const cloudFunction = handlerContainer[functionObj.handler]; + if (!cloudFunction) { + throw new Error(`Failed to load function "${functionObj.handler}" from the loaded file`); + } + + 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, + }; + } + + this.serverless.cli.consoleLog(chalk.red(JSON.stringify(errorResult, null, 4))); + process.exitCode = 1; + } + + function handleResult(result) { + if (result instanceof Error) { + handleError.call(this, result); + return; + } + this.serverless.cli.consoleLog(JSON.stringify(result, null, 4)); + } + + return new Promise((resolve) => { + const callback = (err, result) => { + if (!hasResponded) { + hasResponded = true; + if (err) { + handleError.call(this, err); + } else if (result) { + handleResult.call(this, result); + } + } + resolve(); + }; + + let context = {}; + + if (customContext) { + context = customContext; + } + + const maybeThennable = cloudFunction(event, context, callback); + if (maybeThennable) { + return Promise.resolve(maybeThennable).then(callback.bind(this, null), callback.bind(this)); + } + + return maybeThennable; + }); + }, + + addEnvironmentVariablesToProcessEnv(functionObj) { + const environmentVariables = this.provider.getConfiguredEnvironment(functionObj); + _.merge(process.env, environmentVariables); + }, +}; diff --git a/invokeLocal/lib/nodeJs.test.js b/invokeLocal/lib/nodeJs.test.js new file mode 100644 index 0000000..e1f72f8 --- /dev/null +++ b/invokeLocal/lib/nodeJs.test.js @@ -0,0 +1,85 @@ +'use strict'; + +const path = require('path'); +const GoogleProvider = require('../../provider/googleProvider'); +const GoogleInvokeLocal = require('../googleInvokeLocal'); +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; + + beforeEach(() => { + serverless = new Serverless(); + serverless.setProvider('google', new GoogleProvider(serverless)); + serverless.service.provider.environment = { + MY_VAR: myVarValue, + }; + serverless.serviceDir = path.join(process.cwd(), 'invokeLocal', 'lib', 'testMocks'); // To load the index.js of the mock folder + serverless.cli.consoleLog = jest.fn(); + googleInvokeLocal = new GoogleInvokeLocal(serverless, {}); + }); + + it('should invoke a sync handler', async () => { + const functionConfig = { + handler: 'syncHandler', + }; + 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', + }; + 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', + }; + 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 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 give the environment variables to the handler', async () => { + const functionConfig = { + handler: 'envHandler', + }; + await googleInvokeLocal.invokeLocalNodeJs(functionConfig, event, 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 new file mode 100644 index 0000000..61eb892 --- /dev/null +++ b/invokeLocal/lib/testMocks/index.js @@ -0,0 +1,32 @@ +/** + * /!\ this file contains fake handlers used in the tests /!\ + */ + +'use strict'; + +module.exports = { + syncHandler: (event, context, callback) => { + // eslint-disable-next-line no-console + console.log('SYNC_HANDLER'); + callback(null, { result: event.name }); + }, + syncHandlerWithError: (event, context, callback) => { + // eslint-disable-next-line no-console + console.log('SYNC_HANDLER'); + callback('SYNC_ERROR'); + }, + asyncHandler: async (event, context) => { + // eslint-disable-next-line no-console + console.log('ASYNC_HANDLER'); + return { result: context.name }; + }, + asyncHandlerWithError: async () => { + // eslint-disable-next-line no-console + console.log('ASYNC_HANDLER'); + throw new Error('ASYNC_ERROR'); + }, + envHandler: async () => { + // eslint-disable-next-line no-console + console.log(process.env.MY_VAR); + }, +}; diff --git a/logs/googleLogs.js b/logs/googleLogs.js index a3657c5..a5baa6e 100644 --- a/logs/googleLogs.js +++ b/logs/googleLogs.js @@ -19,6 +19,7 @@ class GoogleLogs { count: { usage: 'Amount of requested logs', shortcut: 'c', + type: 'string', }, }, }, diff --git a/logs/googleLogs.test.js b/logs/googleLogs.test.js index 7173505..ad83df3 100644 --- a/logs/googleLogs.test.js +++ b/logs/googleLogs.test.js @@ -52,6 +52,10 @@ describe('GoogleLogs', () => { expect(googleLogs.commands.logs.options.count).not.toEqual(undefined); }); + it('should have the option "count" with type "string"', () => { + expect(googleLogs.commands.logs.options.count.type).toEqual('string'); + }); + describe('hooks', () => { let validateStub; let setDefaultsStub; diff --git a/logs/lib/retrieveLogs.js b/logs/lib/retrieveLogs.js index 28ce8cf..32f0e2a 100644 --- a/logs/lib/retrieveLogs.js +++ b/logs/lib/retrieveLogs.js @@ -13,7 +13,7 @@ module.exports = { getLogs() { const project = this.serverless.service.provider.project; let func = this.options.function; - const pageSize = this.options.count || 10; + const pageSize = parseInt(this.options.count, 10) || 10; func = getGoogleCloudFunctionName(this.serverless.service.functions, func); diff --git a/logs/lib/retrieveLogs.test.js b/logs/lib/retrieveLogs.test.js index fb34d82..5b9156f 100644 --- a/logs/lib/retrieveLogs.test.js +++ b/logs/lib/retrieveLogs.test.js @@ -97,6 +97,22 @@ describe('RetrieveLogs', () => { }); }); + it('should parse the "count" option as an integer', () => { + googleLogs.options.function = 'func1'; + googleLogs.options.count = '100'; + + return googleLogs.getLogs().then(() => { + expect( + requestStub.calledWithExactly('logging', 'entries', 'list', { + filter: 'resource.labels.function_name="full-function-name" AND NOT textPayload=""', + orderBy: 'timestamp desc', + resourceNames: ['projects/my-project'], + pageSize: parseInt(googleLogs.options.count, 10), + }) + ).toEqual(true); + }); + }); + it('should throw an error if the function could not be found in the service', () => { googleLogs.options.function = 'missingFunc'; diff --git a/package.json b/package.json index c7a40aa..d7f84b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-google-cloudfunctions", - "version": "4.0.0", + "version": "4.1.0", "description": "Provider plugin for the Serverless Framework v1.x which adds support for Google Cloud Functions.", "author": "serverless.com", "repository": "serverless/serverless-google-cloudfunctions", @@ -43,6 +43,7 @@ "bluebird": "^3.7.2", "chalk": "^3.0.0", "fs-extra": "^8.1.0", + "get-stdin": "^8.0.0", "googleapis": "^50.0.0", "lodash": "^4.17.21" }, @@ -50,16 +51,16 @@ "@commitlint/cli": "^9.1.2", "@serverless/eslint-config": "^2.2.0", "coveralls": "^3.1.0", - "eslint": "^7.24.0", - "eslint-plugin-import": "^2.22.1", + "eslint": "^7.28.0", + "eslint-plugin-import": "^2.23.4", "git-list-updated": "^1.2.1", "github-release-from-cc-changelog": "^2.2.0", - "husky": "^4.2.5", + "husky": "^4.3.8", "jest": "^25.5.4", "lint-staged": "^10.5.4", - "prettier": "^2.2.1", + "prettier": "^2.3.1", "sinon": "^8.1.1", - "standard-version": "^9.2.0" + "standard-version": "^9.3.0" }, "scripts": { "commitlint": "commitlint -f HEAD@{15}", diff --git a/package/lib/compileFunctions.js b/package/lib/compileFunctions.js index c9dd256..535b55e 100644 --- a/package/lib/compileFunctions.js +++ b/package/lib/compileFunctions.js @@ -6,6 +6,7 @@ const path = require('path'); const _ = require('lodash'); const BbPromise = require('bluebird'); +const { validateEventsProperty } = require('../../shared/validate'); module.exports = { compileFunctions() { @@ -40,17 +41,11 @@ module.exports = { _.get(funcObject, 'memorySize') || _.get(this, 'serverless.service.provider.memorySize') || 256; - funcTemplate.properties.runtime = - _.get(funcObject, 'runtime') || - _.get(this, 'serverless.service.provider.runtime') || - 'nodejs10'; + funcTemplate.properties.runtime = this.provider.getRuntime(funcObject); funcTemplate.properties.timeout = _.get(funcObject, 'timeout') || _.get(this, 'serverless.service.provider.timeout') || '60s'; - funcTemplate.properties.environmentVariables = _.merge( - {}, - _.get(this, 'serverless.service.provider.environment'), - funcObject.environment // eslint-disable-line comma-dangle - ); + funcTemplate.properties.environmentVariables = + this.provider.getConfiguredEnvironment(funcObject); if (!funcTemplate.properties.serviceAccountEmail) { delete funcTemplate.properties.serviceAccountEmail; @@ -113,36 +108,6 @@ const validateHandlerProperty = (funcObject, functionName) => { } }; -const validateEventsProperty = (funcObject, functionName) => { - if (!funcObject.events || funcObject.events.length === 0) { - const errorMessage = [ - `Missing "events" property for function "${functionName}".`, - ' Your function needs at least one "event".', - ' Please check the docs for more info.', - ].join(''); - throw new Error(errorMessage); - } - - if (funcObject.events.length > 1) { - const errorMessage = [ - `The function "${functionName}" has more than one event.`, - ' Only one event per function is supported.', - ' Please check the docs for more info.', - ].join(''); - throw new Error(errorMessage); - } - - const supportedEvents = ['http', 'event']; - const eventType = Object.keys(funcObject.events[0])[0]; - if (supportedEvents.indexOf(eventType) === -1) { - const errorMessage = [ - `Event type "${eventType}" of function "${functionName}" not supported.`, - ` supported event types are: ${supportedEvents.join(', ')}`, - ].join(''); - throw new Error(errorMessage); - } -}; - const validateVpcConnectorProperty = (funcObject, functionName) => { if (funcObject.vpc && typeof funcObject.vpc === 'string') { const vpcNamePattern = /projects\/[\s\S]*\/locations\/[\s\S]*\/connectors\/[\s\S]*/i; diff --git a/provider/googleProvider.js b/provider/googleProvider.js index 3bb85bd..d5d6cf2 100644 --- a/provider/googleProvider.js +++ b/provider/googleProvider.js @@ -239,6 +239,22 @@ class GoogleProvider { throw new Error(errorMessage); } } + + getRuntime(funcObject) { + return ( + _.get(funcObject, 'runtime') || + _.get(this, 'serverless.service.provider.runtime') || + 'nodejs10' + ); + } + + getConfiguredEnvironment(funcObject) { + return _.merge( + {}, + _.get(this, 'serverless.service.provider.environment'), + funcObject.environment + ); + } } module.exports = GoogleProvider; diff --git a/provider/googleProvider.test.js b/provider/googleProvider.test.js index 7163088..efb3460 100644 --- a/provider/googleProvider.test.js +++ b/provider/googleProvider.test.js @@ -14,12 +14,15 @@ describe('GoogleProvider', () => { let setProviderStub; let homedirStub; + const providerRuntime = 'providerRuntime'; + beforeEach(() => { serverless = new Serverless(); serverless.version = '1.0.0'; serverless.service = { provider: { project: 'example-project', + runtime: providerRuntime, }, }; setProviderStub = sinon.stub(serverless, 'setProvider').returns(); @@ -174,4 +177,57 @@ describe('GoogleProvider', () => { }).toThrow(Error); }); }); + + describe('#getRuntime()', () => { + it('should return the runtime of the function if defined', () => { + const functionRuntime = 'functionRuntime'; + expect(googleProvider.getRuntime({ runtime: functionRuntime })).toEqual(functionRuntime); + }); + + it('should return the runtime of the provider if not defined in the function', () => { + expect(googleProvider.getRuntime({})).toEqual(providerRuntime); + }); + + it('should return nodejs10 if neither the runtime of the function nor the one of the provider are defined', () => { + serverless.service.provider.runtime = undefined; + expect(googleProvider.getRuntime({})).toEqual('nodejs10'); + }); + }); + + describe('#getConfiguredEnvironment()', () => { + const functionEnvironment = { + MY_VAR: 'myVarFunctionValue', + FUNCTION_VAR: 'functionVarFunctionValue', + }; + const providerEnvironment = { + MY_VAR: 'myVarProviderValue', + PROVIDER_VAR: 'providerVarProviderValue', + }; + + it('should return the environment of the function if defined', () => { + expect(googleProvider.getConfiguredEnvironment({ environment: functionEnvironment })).toEqual( + functionEnvironment + ); + }); + + it('should return the environment of the provider if defined', () => { + serverless.service.provider.environment = providerEnvironment; + expect(googleProvider.getConfiguredEnvironment({})).toEqual(providerEnvironment); + }); + + it('should return an empty object if neither the environment of the function nor the one of the provider are defined', () => { + expect(googleProvider.getConfiguredEnvironment({})).toEqual({}); + }); + + it('should return the merged environment of the provider and the function. The function override the provider.', () => { + serverless.service.provider.environment = providerEnvironment; + expect(googleProvider.getConfiguredEnvironment({ environment: functionEnvironment })).toEqual( + { + MY_VAR: 'myVarFunctionValue', + FUNCTION_VAR: 'functionVarFunctionValue', + PROVIDER_VAR: 'providerVarProviderValue', + } + ); + }); + }); }); diff --git a/shared/utils.js b/shared/utils.js index 9558d42..1475cc9 100644 --- a/shared/utils.js +++ b/shared/utils.js @@ -7,7 +7,7 @@ module.exports = { setDefaults() { this.options.stage = _.get(this, 'options.stage') || _.get(this, 'serverless.service.provider.stage') || 'dev'; - this.options.runtime = _.get(this, 'options.runtime') || 'nodejs8'; + this.options.runtime = _.get(this, 'options.runtime') || 'nodejs10'; // serverless framework is hard-coding us-east-1 region from aws // this is temporary fix for multiple regions diff --git a/shared/utils.test.js b/shared/utils.test.js index 6c6cb6a..8c9c217 100644 --- a/shared/utils.test.js +++ b/shared/utils.test.js @@ -24,7 +24,7 @@ describe('Utils', () => { googleCommand.setDefaults().then(() => { expect(googleCommand.options.stage).toEqual('dev'); expect(googleCommand.options.region).toEqual('us-central1'); - expect(googleCommand.options.runtime).toEqual('nodejs8'); + expect(googleCommand.options.runtime).toEqual('nodejs10'); })); it('should set the options when they are provided', () => { diff --git a/shared/validate.js b/shared/validate.js index 863acee..ce77214 100644 --- a/shared/validate.js +++ b/shared/validate.js @@ -51,4 +51,33 @@ module.exports = { } }); }, + + validateEventsProperty(funcObject, functionName, supportedEvents = ['http', 'event']) { + if (!funcObject.events || funcObject.events.length === 0) { + const errorMessage = [ + `Missing "events" property for function "${functionName}".`, + ' Your function needs at least one "event".', + ' Please check the docs for more info.', + ].join(''); + throw new Error(errorMessage); + } + + if (funcObject.events.length > 1) { + const errorMessage = [ + `The function "${functionName}" has more than one event.`, + ' Only one event per function is supported.', + ' Please check the docs for more info.', + ].join(''); + throw new Error(errorMessage); + } + + const eventType = Object.keys(funcObject.events[0])[0]; + if (supportedEvents.indexOf(eventType) === -1) { + const errorMessage = [ + `Event type "${eventType}" of function "${functionName}" not supported.`, + ` supported event types are: ${supportedEvents.join(', ')}`, + ].join(''); + throw new Error(errorMessage); + } + }, }; diff --git a/shared/validate.test.js b/shared/validate.test.js index 186cc5b..de84c96 100644 --- a/shared/validate.test.js +++ b/shared/validate.test.js @@ -113,4 +113,61 @@ describe('Validate', () => { expect(() => googleCommand.validateHandlers()).not.toThrow(Error); }); }); + + describe('#validateEventsProperty()', () => { + const functionName = 'functionName'; + const eventEvent = { + event: {}, + }; + const httpEvent = { + http: {}, + }; + const unknownEvent = { + unknown: {}, + }; + + it('should throw if the configuration of function has no events', () => { + expect(() => validate.validateEventsProperty({}, functionName)).toThrow(); + }); + + it('should throw if the configuration of function has an empty events array', () => { + expect(() => validate.validateEventsProperty({ events: [] }, functionName)).toThrow(); + }); + + it('should throw if the configuration of function has more than one events', () => { + expect(() => + validate.validateEventsProperty({ events: [eventEvent, httpEvent] }, functionName) + ).toThrow(); + }); + + it('should throw if the configuration of function has an unknown event', () => { + expect(() => + validate.validateEventsProperty({ events: [unknownEvent] }, functionName) + ).toThrow(); + }); + + it('should pass if the configuration of function has an http event', () => { + expect(() => + validate.validateEventsProperty({ events: [httpEvent] }, functionName) + ).not.toThrow(); + }); + + it('should pass if the configuration of function has an event event', () => { + expect(() => + validate.validateEventsProperty({ events: [eventEvent] }, functionName) + ).not.toThrow(); + }); + + it('should throw if the configuration of function has an http event but http event is not supported', () => { + expect(() => + validate.validateEventsProperty({ events: [unknownEvent] }, functionName, ['event']) + ).toThrow(); + }); + + it('should pass if the configuration of function has an event event and event is supported', () => { + expect(() => + validate.validateEventsProperty({ events: [eventEvent] }, functionName, ['event']) + ).not.toThrow(); + }); + }); }); diff --git a/test/serverless.js b/test/serverless.js index 9529555..3397fac 100644 --- a/test/serverless.js +++ b/test/serverless.js @@ -19,6 +19,7 @@ class Serverless { } return this.functions[functionName]; }; + this.service.provider = {}; this.utils = { writeFileSync() {}, readFileSync() {}, @@ -39,6 +40,8 @@ class Serverless { defineProvider: jest.fn(), defineFunctionEvent: jest.fn(), }; + + this.processedInput = {}; } setProvider(name, provider) {