Skip to content
This repository was archived by the owner on Dec 9, 2024. It is now read-only.

Commit 50ff6d9

Browse files
authored
feat: Invoke function locally (#264)
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
1 parent 1c82196 commit 50ff6d9

File tree

4 files changed

+134
-60
lines changed

4 files changed

+134
-60
lines changed

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const configConstants = {
3434
scmVfsPath: "/api/vfs/site/wwwroot/",
3535
scmZipDeployApiPath: "/api/zipdeploy",
3636
resourceGroupHashLength: 6,
37+
defaultLocalPort: 7071,
3738
};
3839

3940
export default configConstants;

src/plugins/invoke/azureInvokePlugin.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,53 @@ export class AzureInvokePlugin extends AzureBasePlugin {
6262
usage: "HTTP method (Default is GET)",
6363
shortcut: "m"
6464
}
65+
},
66+
commands: {
67+
local: {
68+
usage: "Invoke a local function",
69+
options: {
70+
function: {
71+
usage: "Function to call",
72+
shortcut: "f",
73+
},
74+
path: {
75+
usage: "Path to file to put in body",
76+
shortcut: "p"
77+
},
78+
data: {
79+
usage: "Data string for body of request",
80+
shortcut: "d"
81+
},
82+
method: {
83+
usage: "HTTP method (Default is GET)",
84+
shortcut: "m"
85+
},
86+
port: {
87+
usage: "Port through which locally running service is exposed",
88+
shortcut: "t"
89+
}
90+
},
91+
lifecycleEvents: [ "local" ],
92+
}
6593
}
6694
}
6795
}
6896

6997
this.hooks = {
70-
"invoke:invoke": this.invoke.bind(this)
98+
"invoke:invoke": this.invokeRemote.bind(this),
99+
"invoke:local:local": this.invokeLocal.bind(this),
71100
};
72101
}
73102

74-
private async invoke() {
103+
private async invokeRemote() {
104+
await this.invoke();
105+
}
106+
107+
private async invokeLocal() {
108+
await this.invoke(true);
109+
}
110+
111+
private async invoke(local: boolean = false) {
75112
const functionName = this.options["function"];
76113
const data = this.options["data"];
77114
const method = this.options["method"] || "GET";
@@ -80,7 +117,7 @@ export class AzureInvokePlugin extends AzureBasePlugin {
80117
return;
81118
}
82119

83-
this.invokeService = new InvokeService(this.serverless, this.options);
120+
this.invokeService = new InvokeService(this.serverless, this.options, local);
84121
const response = await this.invokeService.invoke(method, functionName, data);
85122
if (response) {
86123
this.log(JSON.stringify(response.data));

src/services/invokeService.test.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,68 +6,79 @@ jest.mock("@azure/arm-appservice")
66
jest.mock("@azure/arm-resources")
77
jest.mock("./functionAppService")
88
import { FunctionAppService } from "./functionAppService";
9+
import configConstants from "../config";
910

1011
describe("Invoke Service ", () => {
1112
const app = MockFactory.createTestSite();
1213
const expectedSite = MockFactory.createTestSite();
1314
const testData = "test-data";
14-
const testResult = "test-data";
15+
const testResult = "test result";
1516
const authKey = "authKey";
1617
const baseUrl = "https://management.azure.com"
1718
const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`;
1819
const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`;
19-
let urlPOST = `http://${app.defaultHostName}/api/hello`;
20-
let urlGET = `http://${app.defaultHostName}/api/hello?name%3D${testData}`;
20+
const functionName = "hello";
21+
const urlPOST = `http://${app.defaultHostName}/api/${functionName}`;
22+
const urlGET = `http://${app.defaultHostName}/api/${functionName}?name%3D${testData}`;
23+
const localUrl = `http://localhost:${configConstants.defaultLocalPort}/api/${functionName}`
2124
let masterKey: string;
25+
let sls = MockFactory.createTestServerless();
26+
let options = {
27+
function: functionName,
28+
data: JSON.stringify({name: testData}),
29+
method: "GET"
30+
} as any;
2231

2332
beforeAll(() => {
2433
const axiosMock = new MockAdapter(axios);
2534
// Master Key
2635
axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey });
2736
// Auth Key
2837
axiosMock.onGet(authKeyUrl).reply(200, authKey);
29-
//Mock url for GET
38+
// Mock url for GET
3039
axiosMock.onGet(urlGET).reply(200, testResult);
31-
//Mock url for POST
40+
// Mock url for POST
3241
axiosMock.onPost(urlPOST).reply(200, testResult);
42+
// Mock url for local POST
43+
axiosMock.onPost(localUrl).reply(200, testResult);
3344
});
34-
45+
3546
beforeEach(() => {
3647
FunctionAppService.prototype.getMasterKey = jest.fn();
3748
FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite));
49+
FunctionAppService.prototype.getFunctionHttpTriggerConfig = jest.fn(() => {
50+
return { url: `${app.defaultHostName}/api/hello` }
51+
}) as any;
52+
sls = MockFactory.createTestServerless();
53+
options = {
54+
function: functionName,
55+
data: JSON.stringify({name: testData}),
56+
method: "GET"
57+
} as any;
3858
});
3959

4060
it("Invokes a function with GET request", async () => {
41-
const sls = MockFactory.createTestServerless();
42-
const options = MockFactory.createTestServerlessOptions();
43-
const expectedResult = {url: `${app.defaultHostName}/api/hello`};
44-
const httpConfig = jest.fn(() => expectedResult);
45-
46-
FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any;
47-
48-
options["function"] = "hello";
49-
options["data"] = `{"name": "${testData}"}`;
50-
options["method"] = "GET";
51-
5261
const service = new InvokeService(sls, options);
53-
const response = await service.invoke(options["method"], options["function"], options["data"]);
62+
const response = await service.invoke(options.method, options.function, options.data);
5463
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
5564
});
5665

5766
it("Invokes a function with POST request", async () => {
58-
const sls = MockFactory.createTestServerless();
59-
const options = MockFactory.createTestServerlessOptions();
60-
const expectedResult = {url: `${app.defaultHostName}/api/hello`};
61-
const httpConfig = jest.fn(() => expectedResult);
62-
FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any;
63-
64-
options["function"] = "hello";
65-
options["data"] = `{"name": "${testData}"}`;
66-
options["method"] = "POST";
67-
6867
const service = new InvokeService(sls, options);
69-
const response = await service.invoke(options["method"], options["function"], options["data"]);
68+
const response = await service.invoke(options.method, options.function, options.data);
69+
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
70+
expect(FunctionAppService.prototype.getFunctionHttpTriggerConfig).toBeCalled();
71+
expect(FunctionAppService.prototype.get).toBeCalled();
72+
expect(FunctionAppService.prototype.getMasterKey).toBeCalled();
73+
});
74+
75+
it("Invokes a local function", async () => {
76+
options.method = "POST";
77+
const service = new InvokeService(sls, options, true);
78+
const response = await service.invoke(options.method, options.function, options.data);
7079
expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult));
80+
expect(FunctionAppService.prototype.getFunctionHttpTriggerConfig).not.toBeCalled();
81+
expect(FunctionAppService.prototype.get).not.toBeCalled();
82+
expect(FunctionAppService.prototype.getMasterKey).not.toBeCalled();
7183
});
72-
73-
});
84+
});

src/services/invokeService.ts

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { BaseService } from "./baseService"
22
import Serverless from "serverless";
33
import axios from "axios";
44
import { FunctionAppService } from "./functionAppService";
5+
import configConstants from "../config";
56

67
export class InvokeService extends BaseService {
78
public functionAppService: FunctionAppService;
89

9-
public constructor(serverless: Serverless, options: Serverless.Options) {
10-
super(serverless, options);
11-
this.functionAppService = new FunctionAppService(serverless, options);
10+
public constructor(serverless: Serverless, options: Serverless.Options, private local: boolean = false) {
11+
super(serverless, options, !local);
12+
if (!local) {
13+
this.functionAppService = new FunctionAppService(serverless, options);
14+
}
1215
}
1316

1417
/**
@@ -25,30 +28,51 @@ export class InvokeService extends BaseService {
2528
this.serverless.cli.log(`Function ${functionName} does not exist`);
2629
return;
2730
}
28-
31+
2932
const eventType = Object.keys(functionObject["events"][0])[0];
3033

3134
if (eventType !== "http") {
3235
this.log("Needs to be an http function");
3336
return;
3437
}
35-
36-
const functionApp = await this.functionAppService.get();
37-
const functionConfig = await this.functionAppService.getFunction(functionApp, functionName);
38-
const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig);
39-
let url = "http://" + httpConfig.url;
38+
39+
let url = await this.getUrl(functionName);
4040

4141
if (method === "GET" && data) {
4242
const queryString = this.getQueryString(data);
4343
url += `?${queryString}`
44-
}
44+
}
4545

46-
this.log(url);
47-
const options = await this.getOptions(method, data);
46+
this.log(`URL for invocation: ${url}`);
47+
48+
const options = await this.getRequestOptions(method, data);
4849
this.log(`Invoking function ${functionName} with ${method} request`);
4950
return await axios(url, options);
5051
}
5152

53+
private async getUrl(functionName: string) {
54+
if (this.local) {
55+
return `${this.getLocalHost()}/api/${this.getConfiguredFunctionRoute(functionName)}`
56+
}
57+
const functionApp = await this.functionAppService.get();
58+
const functionConfig = await this.functionAppService.getFunction(functionApp, functionName);
59+
const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig);
60+
return "http://" + httpConfig.url;
61+
}
62+
63+
private getLocalHost() {
64+
return `http://localhost:${this.getOption("port", configConstants.defaultLocalPort)}`
65+
}
66+
67+
private getConfiguredFunctionRoute(functionName: string) {
68+
try {
69+
const { route } = this.config.functions[functionName].events[0]["x-azure-settings"];
70+
return route || functionName
71+
} catch {
72+
return functionName;
73+
}
74+
}
75+
5276
private getQueryString(eventData: any) {
5377
if (typeof eventData === "string") {
5478
try {
@@ -65,23 +89,24 @@ export class InvokeService extends BaseService {
6589
}
6690

6791
/**
68-
* Get options object
69-
* @param method The method used (POST or GET)
70-
* @param data Data to use as body or query params
71-
*/
72-
private async getOptions(method: string, data?: any) {
73-
74-
const functionsAdminKey = await this.functionAppService.getMasterKey();
75-
const functionApp = await this.functionAppService.get();
92+
* Get options object
93+
* @param method The method used (POST or GET)
94+
* @param data Data to use as body or query params
95+
*/
96+
private async getRequestOptions(method: string, data?: any) {
97+
const host = (this.local) ? this.getLocalHost() : await this.functionAppService.get();
7698
const options: any = {
77-
host: functionApp.defaultHostName,
78-
headers: {
79-
"x-functions-key": functionsAdminKey
80-
},
99+
host,
81100
method,
82101
data,
83102
};
84-
103+
104+
if (!this.local) {
105+
options.headers = {
106+
"x-functions-key": await this.functionAppService.getMasterKey(),
107+
}
108+
}
109+
85110
return options;
86111
}
87-
}
112+
}

0 commit comments

Comments
 (0)