Skip to content

Commit

Permalink
feat: Invoke APIM endpoint (#442)
Browse files Browse the repository at this point in the history
  • Loading branch information
tbarlow12 committed Apr 30, 2020
1 parent c0cda45 commit 8b61c38
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 64 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ If you have your service running locally (in another terminal), you can run:
$ sls invoke local -f hello -p data.json
```

If you configured your function app to [run with APIM](./docs/examples/apim.md), you can run:

```bash
$ sls invoke apim -f hello -p data.json
```

### Roll Back Your Function App

To roll back your function app to a previous deployment, simply select a timestamp of a previous deployment and use `rollback` command.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"lodash": "^4.16.6",
"md5": "^2.2.1",
"open": "^6.3.0",
"querystring": "^0.2.0",
"request": "^2.81.0",
"rimraf": "^2.7.1",
"semver": "^6.3.0",
Expand Down
54 changes: 20 additions & 34 deletions src/plugins/invoke/azureInvokePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "fs";
import path, { isAbsolute } from "path";
import Serverless from "serverless";
import { InvokeService } from "../../services/invokeService";
import { InvokeService, InvokeMode } from "../../services/invokeService";
import { AzureBasePlugin } from "../azureBasePlugin";
import { constants } from "../../shared/constants";

Expand All @@ -16,73 +16,59 @@ export class AzureInvokePlugin extends AzureBasePlugin {
lifecycleEvents: ["invoke"],
options: {
...constants.deployedServiceOptions,
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"
}
...constants.invokeOptions,
},
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"
},
...constants.invokeOptions,
port: {
usage: "Port through which locally running service is exposed",
shortcut: "t"
}
},
lifecycleEvents: [ "local" ],
}
},
apim: {
usage: "Invoke a function via APIM",
options: {
...constants.invokeOptions,
},
lifecycleEvents: [ "apim" ],
},
}
}
}

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

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

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

private async invokeApim() {
await this.invoke(InvokeMode.APIM);
}

private async invoke(local: boolean = false) {
private async invoke(mode: InvokeMode) {
const functionName = this.options["function"];
const method = this.options["method"] || "GET";
if (!functionName) {
this.log("Need to provide a name of function to invoke");
return;
}

const invokeService = new InvokeService(this.serverless, this.options, local);
const invokeService = new InvokeService(this.serverless, this.options, mode);
const response = await invokeService.invoke(method, functionName, this.getData());
if (response) {
this.log(JSON.stringify(response.data));
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/login/loginHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export const loginHooks = [
"invoke:invoke",
"rollback:rollback",
"remove:remove",
"info:info"
"info:info",
"invoke:apim:apim",
]
1 change: 0 additions & 1 deletion src/services/apimService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import _ from "lodash";
import Serverless from "serverless";
import { Runtime } from "../config/runtime";
import { ApiCheckHeaderPolicy, ApiIpFilterPolicy, ApiManagementConfig } from "../models/apiManagement";
import { constants } from "../shared/constants";
import { MockFactory } from "../test/mockFactory";
import apimGetApi200 from "../test/responses/apim-get-api-200.json";
import apimGetApi404 from "../test/responses/apim-get-api-404.json";
Expand Down
27 changes: 23 additions & 4 deletions src/services/invokeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { configConstants } from "../config/constants";
import { MockFactory } from "../test/mockFactory";
import { InvokeService } from "./invokeService";
import { InvokeService, InvokeMode } from "./invokeService";

jest.mock("@azure/arm-appservice")
jest.mock("@azure/arm-resources")

jest.mock("./functionAppService");
import { FunctionAppService } from "./functionAppService";
import { ApimResource } from "../armTemplates/resources/apim";


describe("Invoke Service ", () => {
Expand All @@ -22,10 +23,13 @@ describe("Invoke Service ", () => {
const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`;
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.defaults.localPort}/api/${functionName}`
const urlGET = `http://${app.defaultHostName}/api/${functionName}?name=${testData}`;
const localUrl = `http://localhost:${configConstants.defaults.localPort}/api/${functionName}`;
const apimUrl = `https://sls-eus2-dev-d99fe2-apim.azure-api.net/api/${functionName}`;

let masterKey: string;
let sls = MockFactory.createTestServerless();

let options = {
function: functionName,
data: JSON.stringify({name: testData}),
Expand All @@ -44,6 +48,8 @@ describe("Invoke Service ", () => {
axiosMock.onPost(urlPOST).reply(200, testResult);
// Mock url for local POST
axiosMock.onPost(localUrl).reply(200, testResult);
// Mock url for APIM POST
axiosMock.onPost(apimUrl).reply(200, testResult);
});

beforeEach(() => {
Expand Down Expand Up @@ -77,14 +83,27 @@ describe("Invoke Service ", () => {

it("Invokes a local function", async () => {
options.method = "POST";
const service = new InvokeService(sls, options, true);
const service = new InvokeService(sls, options, InvokeMode.LOCAL);
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();
});

it("Invokes an APIM function", async () => {
options.method = "POST";
const getResourceNameSpy = jest.spyOn(ApimResource, "getResourceName");
const service = new InvokeService(sls, options, InvokeMode.APIM);
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();
// Get Master Key should still be called for APIM
expect(FunctionAppService.prototype.getMasterKey).toBeCalled();
expect(getResourceNameSpy).toBeCalled();
});

it("Does not try to invoke a non-existent function", async () => {
const service = new InvokeService(sls, options);
const fakeName = "fakeFunction";
Expand Down
54 changes: 36 additions & 18 deletions src/services/invokeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@ import Serverless from "serverless";
import axios from "axios";
import { FunctionAppService } from "./functionAppService";
import { configConstants } from "../config/constants";
import { ApimResource } from "../armTemplates/resources/apim";
import { stringify } from "querystring"

export enum InvokeMode {
FUNCTION,
LOCAL,
APIM,
}

export class InvokeService extends BaseService {
public functionAppService: FunctionAppService;
private local: boolean;
private mode: InvokeMode;

public constructor(serverless: Serverless, options: Serverless.Options, local: boolean = false) {
public constructor(serverless: Serverless, options: Serverless.Options, mode: InvokeMode = InvokeMode.FUNCTION) {
const local = mode === InvokeMode.LOCAL;
super(serverless, options, !local);
this.local = local;
this.mode = mode;
if (!local) {
this.functionAppService = new FunctionAppService(serverless, options);
}
Expand Down Expand Up @@ -39,22 +48,19 @@ export class InvokeService extends BaseService {
}

let url = await this.getUrl(functionName);

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

this.log(`URL for invocation: ${url}`);

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

private async getUrl(functionName: string) {
if (this.local) {
return `${this.getLocalHost()}/api/${this.getConfiguredFunctionRoute(functionName)}`
if (this.mode === InvokeMode.LOCAL || this.mode === InvokeMode.APIM) {
return `${this.getHost()}/api/${this.getConfiguredFunctionRoute(functionName)}`
}
const functionApp = await this.functionAppService.get();
const functionConfig = await this.functionAppService.getFunction(functionApp, functionName);
Expand All @@ -66,6 +72,10 @@ export class InvokeService extends BaseService {
return `http://localhost:${this.getOption("port", configConstants.defaults.localPort)}`
}

private getApimHost() {
return `https://${ApimResource.getResourceName(this.config)}.azure-api.net`
}

private getConfiguredFunctionRoute(functionName: string) {
try {
const { route } = this.config.functions[functionName].events[0];
Expand All @@ -75,19 +85,17 @@ export class InvokeService extends BaseService {
}
}

private getQueryString(eventData: any) {
private getQueryString(eventData: any): string {
if (typeof eventData === "string") {
try {
eventData = JSON.parse(eventData);
}
catch (error) {
return Promise.reject("The specified input data isn't a valid JSON string. " +
throw new Error("The specified input data isn't a valid JSON string. " +
"Please correct it and try invoking the function again.");
}
}
return encodeURIComponent(Object.keys(eventData)
.map((key) => `${key}=${eventData[key]}`)
.join("&"));
return stringify(eventData);
}

/**
Expand All @@ -96,19 +104,29 @@ export class InvokeService extends BaseService {
* @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 host = (this.mode === InvokeMode.LOCAL) ? this.getLocalHost()
: (this.mode === InvokeMode.APIM) ? this.getApimHost()
: await this.functionAppService.get();
const options: any = {
host,
method,
data,
};

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

return options;
}

private getHost() {
if (this.mode === InvokeMode.LOCAL) {
return this.getLocalHost();
} else if (this.mode === InvokeMode.APIM) {
return this.getApimHost();
}
}
}
18 changes: 18 additions & 0 deletions src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,23 @@ export const constants = {
usage: "Deployment of individual function - NOT SUPPORTED",
shortcut: "f",
}
},
invokeOptions: {
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"
},
}
}
12 changes: 6 additions & 6 deletions src/test/mockFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import Serverless from "serverless";
import Service from "serverless/classes/Service";
import Utils from "serverless/classes/Utils";
import PluginManager from "serverless/lib/classes/PluginManager";
import { ApiCorsPolicy, ApiManagementConfig, ApiJwtValidatePolicy } from "../models/apiManagement";
import { ArmDeployment, ArmResourceTemplate, ArmTemplateProvisioningState, ArmParameters, ArmParamType } from "../models/armTemplates";
import { ServicePrincipalEnvVariables } from "../models/azureProvider";
import { Logger } from "../models/generic";
import { ServerlessAzureConfig, ServerlessAzureProvider, ServerlessAzureFunctionConfig, ServerlessCliCommand, ServerlessAzureFunctionBindingConfig } from "../models/serverless";
import { configConstants } from "../config/constants";
import { Runtime } from "../config/runtime";
import { ApiCorsPolicy, ApiJwtValidatePolicy, ApiManagementConfig } from "../models/apiManagement";
import { ArmDeployment, ArmParameters, ArmParamType, ArmResourceTemplate, ArmTemplateProvisioningState } from "../models/armTemplates";
import { ServicePrincipalEnvVariables } from "../models/azureProvider";
import { Logger } from "../models/generic";
import { ServerlessAzureConfig, ServerlessAzureFunctionBindingConfig, ServerlessAzureFunctionConfig, ServerlessAzureProvider, ServerlessCliCommand } from "../models/serverless";

function getAttribute(object: any, prop: string, defaultValue: any): any {
if (object && object[prop]) {
Expand Down Expand Up @@ -360,7 +360,7 @@ export class MockFactory {
]
}

public static createTestFunctionMetadataWithXAzureSettings(name: string, xAzureSettings: boolean = true): ServerlessAzureFunctionConfig {
public static createTestFunctionMetadataWithXAzureSettings(name: string): ServerlessAzureFunctionConfig {
return {
"handler": `${name}.handler`,
"events": MockFactory.createTestFunctionEventsWithXAzureSettings(),
Expand Down

0 comments on commit 8b61c38

Please sign in to comment.