diff --git a/src/plugins/invoke/azureInvoke.test.ts b/src/plugins/invoke/azureInvoke.test.ts new file mode 100644 index 00000000..7f45eb47 --- /dev/null +++ b/src/plugins/invoke/azureInvoke.test.ts @@ -0,0 +1,98 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import mockFs from "mock-fs"; +import { AzureInvoke } from "./azureInvoke"; +import { InvokeService } from "../../services/invokeService"; + +jest.mock("../../services/functionAppService"); +jest.mock("../../services/resourceService"); +jest.mock("../../services/invokeService"); + +describe("Azure Invoke Plugin", () => { + const fileContent = JSON.stringify({ + name: "Azure-Test", + }); + afterEach(() => { + jest.resetAllMocks(); + }) + + beforeAll(() => { + mockFs({ + "testFile.json": fileContent, + }, { createCwd: true, createTmp: true }); + }); + afterAll(() => { + mockFs.restore(); + }); + + it("calls invoke hook", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["function"], options["data"], options["method"]); + expect(sls.cli.log).toBeCalledWith(expectedResult.data); + }); + + it("calls the invoke hook with file path", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "testFile.json"; + options["method"] = "GET"; + + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["function"], fileContent, options["method"]); + expect(sls.cli.log).toBeCalledWith(expectedResult.data); + + }); + + it("calls the invooke hook with file path", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "garbage.json"; + options["method"] = "GET"; + expect(() => new AzureInvoke(sls, options)).toThrow(); + }); + + it("The invoke function fails when no data is passsed", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["data"] = null; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).not.toBeCalled(); + }); + + it("The invoke function fails when no function name is passed", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = null; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).not.toBeCalled(); + }); +}); diff --git a/src/plugins/invoke/azureInvoke.ts b/src/plugins/invoke/azureInvoke.ts index f8da7e26..804f686b 100644 --- a/src/plugins/invoke/azureInvoke.ts +++ b/src/plugins/invoke/azureInvoke.ts @@ -1,41 +1,79 @@ +import { isAbsolute, join } from "path"; import Serverless from "serverless"; -import { join, isAbsolute } from "path"; import AzureProvider from "../../provider/azureProvider"; +import { InvokeService } from "../../services/invokeService"; +import { AzureLoginPlugin } from "../login/loginPlugin"; +import fs from "fs"; export class AzureInvoke { public hooks: { [eventName: string]: Promise }; + private commands: any; private provider: AzureProvider; - + private login: AzureLoginPlugin + private invokeService: InvokeService; public constructor(private serverless: Serverless, private options: Serverless.Options) { this.provider = (this.serverless.getProvider("azure") as any) as AzureProvider; const path = this.options["path"]; - + this.login = new AzureLoginPlugin(this.serverless, this.options); + if (path) { const absolutePath = isAbsolute(path) ? path : join(this.serverless.config.servicePath, path); + this.serverless.cli.log(this.serverless.config.servicePath); + this.serverless.cli.log(path); - if (!this.serverless.utils.fileExistsSync(absolutePath)) { + if (!fs.existsSync(absolutePath)) { throw new Error("The file you provided does not exist."); } - this.options["data"] = this.serverless.utils.readFileSync(absolutePath); + this.options["data"] = fs.readFileSync(absolutePath).toString(); + } + this.commands = { + invoke: { + usage: "Invoke command", + lifecycleEvents: [ + "invoke" + ], + options: { + function: { + usage: "Function to call", + shortcut: "f", + }, + path: { + usage: "Path to file to put in body", + shortcut: "p" + }, + data: { + usage: "Data string for body of request", + shortcut: "d" + }, + method: { + usage: "GET or POST request (Default is GET)", + shortcut: "m" + } + } + } } - this.hooks = { - "before:invoke:invoke": this.provider.getAdminKey.bind(this), "invoke:invoke": this.invoke.bind(this) }; + } private async invoke() { - const func = this.options.function; - const functionObject = this.serverless.service.getFunction(func); - const eventType = Object.keys(functionObject["events"][0])[0]; - - if (!this.options["data"]) { - this.options["data"] = {}; + const functionName = this.options["function"]; + const data = this.options["data"]; + const method = this.options["method"] || "GET"; + if (!functionName) { + this.serverless.cli.log("Need to provide a name of function to invoke"); + return; } - - return this.provider.invoke(func, eventType, this.options["data"]); + if (!data) { + this.serverless.cli.log("Need to provide data or path"); + return; + } + this.invokeService = new InvokeService(this.serverless, this.options); + const response = await this.invokeService.invoke(functionName, data, method); + this.serverless.cli.log(response.data); } -} +} \ No newline at end of file diff --git a/src/services/invokeService.test.ts b/src/services/invokeService.test.ts new file mode 100644 index 00000000..6754bece --- /dev/null +++ b/src/services/invokeService.test.ts @@ -0,0 +1,47 @@ +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { MockFactory } from "../test/mockFactory"; +import { InvokeService } from "./invokeService"; +import { FunctionAppService } from "./functionAppService"; +jest.mock("@azure/arm-appservice") +jest.mock("@azure/arm-resources") + +describe("Invoke Service ", () => { + const app = MockFactory.createTestSite(); + const slsService = MockFactory.createTestService(); + const testData = "test-data"; + const result = "result"; + const authKey = "authKey"; + const baseUrl = "https://management.azure.com" + const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`; + const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`; + const functionUrl = `http://${slsService.getServiceName()}.azurewebsites.net/api/hello?name=${testData}`; + let masterKey: string; + + beforeAll(() => { + + const axiosMock = new MockAdapter(axios); + + // Master Key + axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey }); + // Auth Key + axiosMock.onGet(authKeyUrl).reply(200, authKey); + axiosMock.onGet(functionUrl).reply(200, result); + }); + beforeEach(() => { + FunctionAppService.prototype.getMasterKey = jest.fn(); + }); + + + it("Invokes a function", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "hello"; + options["data"] = `{"name": "${testData}"}`; + options["method"] = "GET"; + + const service = new InvokeService(sls, options); + const response = await service.invoke(options["function"], options["data"], options["method"]); + expect(response.data).toEqual(result); + }); +}); \ No newline at end of file diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts new file mode 100644 index 00000000..e836fc47 --- /dev/null +++ b/src/services/invokeService.ts @@ -0,0 +1,84 @@ +import { BaseService } from "./baseService" +import Serverless from "serverless"; +import config from "../config"; +import axios from "axios"; +import { FunctionAppService } from "./functionAppService"; + +export class InvokeService extends BaseService { + public serverless: Serverless; + public options: Serverless.Options; + public constructor(serverless: Serverless, options: Serverless.Options) { + super(serverless, options); + this.serverless = serverless; + this.options = options; + } + + /** + * Invoke an Azure Function + * @param functionName Name of function to invoke + * @param data Data to use as body or query params + * @param method GET or POST + */ + public async invoke(functionName: string, data: any, method: string){ + /* accesses the admin key */ + if (!(functionName in this.slsFunctions())) { + this.serverless.cli.log(`Function ${functionName} does not exist`); + return; + } + const functionObject = this.slsFunctions()[functionName]; + const eventType = Object.keys(functionObject["events"][0])[0]; + if (eventType !== "http") { + this.log("Needs to be an http function"); + return; + } + let url = `http://${this.serviceName}${config.functionAppDomain}${config.functionAppApiPath + functionName}`; + + if (method === "GET") { + const queryString = this.getQueryString(data); + url += `?${queryString}` + } + + this.log(url); + const options = await this.getOptions(method, data); + this.log(`Invoking function ${functionName} with ${method} request`); + return await axios(url, options); + } + + private getQueryString(eventData: any) { + if (typeof eventData === "string") { + try { + eventData = JSON.parse(eventData); + } + catch (error) { + return Promise.reject("The specified input data isn't a valid JSON string. " + + "Please correct it and try invoking the function again."); + } + } + return Object.keys(eventData) + .map((key) => `${key}=${eventData[key]}`) + .join("&"); + } + + /** + * Get options object + * @param method The method used (POST or GET) + * @param data Data to use as body or query params + */ + private async getOptions(method: string, data?: any) { + + const functionAppService = new FunctionAppService(this.serverless, this.options); + const functionsAdminKey = await functionAppService.getMasterKey(); + this.log(functionsAdminKey); + const options: any = { + host: config.functionAppDomain, + headers: { + "x-functions-key": functionsAdminKey + }, + method, + }; + if (method === "POST" && data) { + options.body = data; + } + return options; + } +} \ No newline at end of file