Skip to content

feat: Add support for invoke local #258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
}
Expand Down
42 changes: 42 additions & 0 deletions invokeLocal/googleInvokeLocal.js
Original file line number Diff line number Diff line change
@@ -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;
190 changes: 190 additions & 0 deletions invokeLocal/googleInvokeLocal.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
59 changes: 59 additions & 0 deletions invokeLocal/lib/getDataAndContext.js
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it depend on the parsing functionality of readFile here? If not, then I would suggest to not use utils.readFile and use native fs to read it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make it as iso as possible with the AWS implementation and it loads the files with this util: https://github.com/serverless/serverless/blob/d5e2baf714958c5718610659887f485f9bd161e4/lib/plugins/aws/invokeLocal/index.js#L62

},

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');
}
},
};
71 changes: 71 additions & 0 deletions invokeLocal/lib/getDataAndContext.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading