Skip to content

Commit

Permalink
feat: Invoke function locally (#264)
Browse files Browse the repository at this point in the history
When Azure Functions running locally via `sls offline`, use the `invoke local` command to invoke the local function. Behaves the same as if the function were running remotely.

Resolves #260
  • Loading branch information
tbarlow12 committed Sep 13, 2019
1 parent 711753c commit 14affa2
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 60 deletions.
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const configConstants = {
scmVfsPath: "/api/vfs/site/wwwroot/",
scmZipDeployApiPath: "/api/zipdeploy",
resourceGroupHashLength: 6,
defaultLocalPort: 7071,
};

export default configConstants;
43 changes: 40 additions & 3 deletions src/plugins/invoke/azureInvokePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,53 @@ export class AzureInvokePlugin extends AzureBasePlugin {
usage: "HTTP method (Default is GET)",
shortcut: "m"
}
},
commands: {
local: {
usage: "Invoke a local function",
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: "HTTP method (Default is GET)",
shortcut: "m"
},
port: {
usage: "Port through which locally running service is exposed",
shortcut: "t"
}
},
lifecycleEvents: [ "local" ],
}
}
}
}

this.hooks = {
"invoke:invoke": this.invoke.bind(this)
"invoke:invoke": this.invokeRemote.bind(this),
"invoke:local:local": this.invokeLocal.bind(this),
};
}

private async invoke() {
private async invokeRemote() {
await this.invoke();
}

private async invokeLocal() {
await this.invoke(true);
}

private async invoke(local: boolean = false) {
const functionName = this.options["function"];
const data = this.options["data"];
const method = this.options["method"] || "GET";
Expand All @@ -80,7 +117,7 @@ export class AzureInvokePlugin extends AzureBasePlugin {
return;
}

this.invokeService = new InvokeService(this.serverless, this.options);
this.invokeService = new InvokeService(this.serverless, this.options, local);
const response = await this.invokeService.invoke(method, functionName, data);
if (response) {
this.log(JSON.stringify(response.data));
Expand Down
73 changes: 42 additions & 31 deletions src/services/invokeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,79 @@ jest.mock("@azure/arm-appservice")
jest.mock("@azure/arm-resources")
jest.mock("./functionAppService")
import { FunctionAppService } from "./functionAppService";
import configConstants from "../config";

describe("Invoke Service ", () => {
const app = MockFactory.createTestSite();
const expectedSite = MockFactory.createTestSite();
const testData = "test-data";
const testResult = "test-data";
const testResult = "test 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`;
let urlPOST = `http://${app.defaultHostName}/api/hello`;
let urlGET = `http://${app.defaultHostName}/api/hello?name%3D${testData}`;
const functionName = "hello";
const urlPOST = `http://${app.defaultHostName}/api/${functionName}`;
const urlGET = `http://${app.defaultHostName}/api/${functionName}?name%3D${testData}`;
const localUrl = `http://localhost:${configConstants.defaultLocalPort}/api/${functionName}`
let masterKey: string;
let sls = MockFactory.createTestServerless();
let options = {
function: functionName,
data: JSON.stringify({name: testData}),
method: "GET"
} as any;

beforeAll(() => {
const axiosMock = new MockAdapter(axios);
// Master Key
axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey });
// Auth Key
axiosMock.onGet(authKeyUrl).reply(200, authKey);
//Mock url for GET
// Mock url for GET
axiosMock.onGet(urlGET).reply(200, testResult);
//Mock url for POST
// Mock url for POST
axiosMock.onPost(urlPOST).reply(200, testResult);
// Mock url for local POST
axiosMock.onPost(localUrl).reply(200, testResult);
});

beforeEach(() => {
FunctionAppService.prototype.getMasterKey = jest.fn();
FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite));
FunctionAppService.prototype.getFunctionHttpTriggerConfig = jest.fn(() => {
return { url: `${app.defaultHostName}/api/hello` }
}) as any;
sls = MockFactory.createTestServerless();
options = {
function: functionName,
data: JSON.stringify({name: testData}),
method: "GET"
} as any;
});

it("Invokes a function with GET request", async () => {
const sls = MockFactory.createTestServerless();
const options = MockFactory.createTestServerlessOptions();
const expectedResult = {url: `${app.defaultHostName}/api/hello`};
const httpConfig = jest.fn(() => expectedResult);

FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any;

options["function"] = "hello";
options["data"] = `{"name": "${testData}"}`;
options["method"] = "GET";

const service = new InvokeService(sls, options);
const response = await service.invoke(options["method"], options["function"], options["data"]);
const response = await service.invoke(options.method, options.function, options.data);
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
});

it("Invokes a function with POST request", async () => {
const sls = MockFactory.createTestServerless();
const options = MockFactory.createTestServerlessOptions();
const expectedResult = {url: `${app.defaultHostName}/api/hello`};
const httpConfig = jest.fn(() => expectedResult);
FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any;

options["function"] = "hello";
options["data"] = `{"name": "${testData}"}`;
options["method"] = "POST";

const service = new InvokeService(sls, options);
const response = await service.invoke(options["method"], options["function"], options["data"]);
const response = await service.invoke(options.method, options.function, options.data);
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
expect(FunctionAppService.prototype.getFunctionHttpTriggerConfig).toBeCalled();
expect(FunctionAppService.prototype.get).toBeCalled();
expect(FunctionAppService.prototype.getMasterKey).toBeCalled();
});

it("Invokes a local function", async () => {
options.method = "POST";
const service = new InvokeService(sls, options, true);
const response = await service.invoke(options.method, options.function, options.data);
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
expect(FunctionAppService.prototype.getFunctionHttpTriggerConfig).not.toBeCalled();
expect(FunctionAppService.prototype.get).not.toBeCalled();
expect(FunctionAppService.prototype.getMasterKey).not.toBeCalled();
});

});
});
77 changes: 51 additions & 26 deletions src/services/invokeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { BaseService } from "./baseService"
import Serverless from "serverless";
import axios from "axios";
import { FunctionAppService } from "./functionAppService";
import configConstants from "../config";

export class InvokeService extends BaseService {
public functionAppService: FunctionAppService;

public constructor(serverless: Serverless, options: Serverless.Options) {
super(serverless, options);
this.functionAppService = new FunctionAppService(serverless, options);
public constructor(serverless: Serverless, options: Serverless.Options, private local: boolean = false) {
super(serverless, options, !local);
if (!local) {
this.functionAppService = new FunctionAppService(serverless, options);
}
}

/**
Expand All @@ -25,30 +28,51 @@ export class InvokeService extends BaseService {
this.serverless.cli.log(`Function ${functionName} does not exist`);
return;
}

const eventType = Object.keys(functionObject["events"][0])[0];

if (eventType !== "http") {
this.log("Needs to be an http function");
return;
}

const functionApp = await this.functionAppService.get();
const functionConfig = await this.functionAppService.getFunction(functionApp, functionName);
const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig);
let url = "http://" + httpConfig.url;

let url = await this.getUrl(functionName);

if (method === "GET" && data) {
const queryString = this.getQueryString(data);
url += `?${queryString}`
}
}

this.log(url);
const options = await this.getOptions(method, data);
this.log(`URL for invocation: ${url}`);

const options = await this.getRequestOptions(method, data);
this.log(`Invoking function ${functionName} with ${method} request`);
return await axios(url, options);
}

private async getUrl(functionName: string) {
if (this.local) {
return `${this.getLocalHost()}/api/${this.getConfiguredFunctionRoute(functionName)}`
}
const functionApp = await this.functionAppService.get();
const functionConfig = await this.functionAppService.getFunction(functionApp, functionName);
const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig);
return "http://" + httpConfig.url;
}

private getLocalHost() {
return `http://localhost:${this.getOption("port", configConstants.defaultLocalPort)}`
}

private getConfiguredFunctionRoute(functionName: string) {
try {
const { route } = this.config.functions[functionName].events[0]["x-azure-settings"];
return route || functionName
} catch {
return functionName;
}
}

private getQueryString(eventData: any) {
if (typeof eventData === "string") {
try {
Expand All @@ -65,23 +89,24 @@ export class InvokeService extends BaseService {
}

/**
* 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 functionsAdminKey = await this.functionAppService.getMasterKey();
const functionApp = await this.functionAppService.get();
* Get options object
* @param method The method used (POST or GET)
* @param data Data to use as body or query params
*/
private async getRequestOptions(method: string, data?: any) {
const host = (this.local) ? this.getLocalHost() : await this.functionAppService.get();
const options: any = {
host: functionApp.defaultHostName,
headers: {
"x-functions-key": functionsAdminKey
},
host,
method,
data,
};


if (!this.local) {
options.headers = {
"x-functions-key": await this.functionAppService.getMasterKey(),
}
}

return options;
}
}
}

0 comments on commit 14affa2

Please sign in to comment.