Repository: serverless/serverless-azure-functions
Pull request #151 (2019-05-28 16:59:23)

feat: Func plugin to add/remove functions within function app
- Adds CLI commands `sls func add -n {functionName}` and `sls func remove -n {functionName}`
- Adds 1 plugin with 3 hooks: (`func`, `func:add`, `func:remove`)
- Generates code directory and updates `serverless.yml` appropriately
- For now, only supports http functions. Working on supporting additional triggers
- Still working on tests, documentation and edge cases


Patch:
diff --git a/README.md b/README.md
index 310a693..1535a58 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,26 @@ This plugin enables Azure Functions support within the Serverless Framework.
 2. CD into the generated app directory: `cd <appName>`
 3. Install the app's NPM dependencies, which includes this plugin: `npm install`
 
+### Creating or removing Azure Functions
+
+To create a new Azure Function within your function app, run the following command from within your app's directory:
+
+```bash
+sls func add -n {functionName}
+```
+
+This will create a new `{functionName}` directory at the root of your application with `index.js` and `function.json` inside the directory. It will also update `serverless.yml` to contain the new function.
+
+To remove an existing Azure Function from your function app, run the following command from within your app's directory:
+
+```bash
+sls func remove -n {functionName}
+```
+
+This will remove the `{functionName}` directory and remove the function from `serverless.yml`
+
+*Note: Add & remove currently only support HTTP triggered functions. For other triggers, you will need to update `serverless.yml` manually
+
 ### Deploy, test, and diagnose your Azure service
 
 1. Deploy your new service to Azure! The first time you do this, you will be asked to authenticate with your Azure account, so the `serverless` CLI can manage Functions on your behalf. Simply follow the provided instructions, and the deployment will continue as soon as the authentication process is completed.
diff --git a/package-lock.json b/package-lock.json
index 08b1f0a..679c8e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -997,7 +997,6 @@
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
-      "dev": true,
       "requires": {
         "sprintf-js": "~1.0.2"
       }
@@ -3985,7 +3984,6 @@
       "version": "7.1.4",
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
       "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
-      "dev": true,
       "requires": {
         "fs.realpath": "^1.0.0",
         "inflight": "^1.0.4",
@@ -6058,7 +6056,6 @@
       "version": "3.13.1",
       "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
       "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
-      "dev": true,
       "requires": {
         "argparse": "^1.0.7",
         "esprima": "^4.0.0"
@@ -6067,8 +6064,7 @@
         "esprima": {
           "version": "4.0.1",
           "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
-          "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
-          "dev": true
+          "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
         }
       }
     },
@@ -7992,7 +7988,6 @@
       "version": "2.6.3",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
       "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
-      "dev": true,
       "requires": {
         "glob": "^7.1.3"
       }
@@ -8627,8 +8622,7 @@
     "sprintf-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
-      "dev": true
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
     },
     "sshpk": {
       "version": "1.16.1",
diff --git a/package.json b/package.json
index ed38212..b038e44 100644
--- a/package.json
+++ b/package.json
@@ -33,10 +33,12 @@
     "@azure/arm-resources": "^1.0.1",
     "@azure/ms-rest-nodeauth": "^1.0.1",
     "axios": "^0.18.0",
+    "js-yaml": "^3.13.1",
     "jsonpath": "^1.0.1",
     "lodash": "^4.16.6",
     "open": "^6.3.0",
-    "request": "^2.81.0"
+    "request": "^2.81.0",
+    "rimraf": "^2.6.3"
   },
   "devDependencies": {
     "@types/jest": "^24.0.13",
diff --git a/src/index.ts b/src/index.ts
index 1dcbbbc..05e9c21 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,6 +14,8 @@ import { AzureDeployPlugin } from "./plugins/deploy/azureDeployPlugin";
 import { AzureLoginPlugin } from "./plugins/login/loginPlugin";
 import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin";
 import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin";
+import { AzureFuncPlugin } from "./plugins/func/azureFunc";
+
 
 export class AzureIndex {
   public constructor(private serverless: Serverless, private options) {
@@ -29,6 +31,7 @@ export class AzureIndex {
     this.serverless.pluginManager.addPlugin(AzureDeployPlugin);
     this.serverless.pluginManager.addPlugin(AzureApimServicePlugin);
     this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin);
+    this.serverless.pluginManager.addPlugin(AzureFuncPlugin);
   }
 }
 
diff --git a/src/plugins/func/azureFunc.ts b/src/plugins/func/azureFunc.ts
new file mode 100644
index 0000000..00d1a21
--- /dev/null
+++ b/src/plugins/func/azureFunc.ts
@@ -0,0 +1,113 @@
+import fs from "fs";
+import path from "path";
+import rimraf from "rimraf";
+import Serverless from "serverless";
+import { FuncPluginUtils } from "./funcUtils";
+
+export class AzureFuncPlugin {
+  public hooks: { [eventName: string]: Promise<any> };
+  public commands: any;
+  
+
+  public constructor(private serverless: Serverless, private options: Serverless.Options) {
+    this.hooks = {
+      "func:func": this.func.bind(this),
+      "func:add:add": this.add.bind(this),
+      "func:remove:remove": this.remove.bind(this)
+    };
+
+    this.commands = {
+      func: {
+        usage: "Add or remove functions",
+        lifecycleEvents: [
+          "func",
+        ],
+        commands: {
+          add: {
+            usage: "Add azure function",
+            lifecycleEvents: [
+              "add",
+            ],
+            options: {
+              name: {
+                usage: "Name of function to add",
+                shortcut: "n",
+              }
+            }
+          },
+          remove: {
+            usage: "Remove azure function",
+            lifecycleEvents: [
+              "remove",
+            ],
+            options: {
+              name: {
+                usage: "Name of function to remove",
+                shortcut: "n",
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  private async func() {
+    this.serverless.cli.log("Use the func plugin to add or remove functions within Function App");
+  }
+
+  private async add() {
+    if (!("name" in this.options)) {
+      this.serverless.cli.log("Need to provide a name of function to add");
+      return;
+    }
+    const funcToAdd = this.options["name"]
+    const exists = fs.existsSync(funcToAdd);
+    if (exists) {
+      this.serverless.cli.log(`Function ${funcToAdd} already exists`);
+      return;
+    }
+    this.createFunctionDir(funcToAdd);
+    this.addToServerlessYml(funcToAdd);
+  }
+
+  private createFunctionDir(name: string) {
+    this.serverless.cli.log("Creating function dir");
+    try {
+      fs.mkdirSync(name);
+    } catch (e) {
+      this.serverless.cli.log(`Error making directory ${e}`);
+    }
+    this.serverless.utils.writeFileSync(path.join(name, "index.js"), FuncPluginUtils.getFunctionHandler(name));
+    this.serverless.utils.writeFileSync(path.join(name, "function.json"), FuncPluginUtils.getFunctionJsonString(name, this.options))
+  }
+
+  private addToServerlessYml(name: string) {
+    this.serverless.cli.log("Adding to serverless.yml");
+    const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless);
+    functionYml[name] = FuncPluginUtils.getFunctionSlsObject(name, this.options);
+    FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml);
+  } 
+
+  private async remove() {
+    if (!("name" in this.options)) {
+      this.serverless.cli.log("Need to provide a name of function to remove");
+      return;
+    }
+    const funcToRemove = this.options["name"];
+    const exists = fs.existsSync(funcToRemove);
+    if (!exists) {
+      this.serverless.cli.log(`Function ${funcToRemove} does not exist`);
+      return;
+    }
+    this.serverless.cli.log(`Removing ${funcToRemove}`);
+    rimraf.sync(funcToRemove);
+    await this.removeFromServerlessYml(funcToRemove);
+  }
+
+  private async removeFromServerlessYml(name: string) {
+    const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless);
+    delete functionYml[name];
+    FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml)
+  }
+}
\ No newline at end of file
diff --git a/src/plugins/func/bindingTemplates/http.json b/src/plugins/func/bindingTemplates/http.json
new file mode 100644
index 0000000..9f2d4d8
--- /dev/null
+++ b/src/plugins/func/bindingTemplates/http.json
@@ -0,0 +1,19 @@
+{
+  "bindings": [
+    {
+      "authLevel": "anonymous",
+      "type": "httpTrigger",
+      "direction": "in",
+      "name": "req",
+      "methods": [
+        "get",
+        "post"
+      ]
+    },
+    {
+      "type": "http",
+      "direction": "out",
+      "name": "res"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/src/plugins/func/funcUtils.ts b/src/plugins/func/funcUtils.ts
new file mode 100644
index 0000000..7d025d5
--- /dev/null
+++ b/src/plugins/func/funcUtils.ts
@@ -0,0 +1,77 @@
+import yaml from "js-yaml";
+import Serverless from "serverless";
+import httpBinding from "./bindingTemplates/http.json"
+
+export class FuncPluginUtils {
+
+  public static getServerlessYml(sls: Serverless) {
+    return sls.utils.readFileSync("serverless.yml");
+  }
+
+  public static getFunctionsYml(sls: Serverless, serverlessYml?: any) {
+    serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls);
+    return serverlessYml["functions"];
+  }
+
+  public static updateFunctionsYml(sls: Serverless, functionYml: any, serverlessYml?: any) {
+    serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls);
+    serverlessYml["functions"] = functionYml;
+    sls.utils.writeFileSync("serverless.yml", yaml.dump(serverlessYml));
+  }
+
+  public static getFunctionHandler(name: string) {
+    return `"use strict";
+
+module.exports.handler = async function (context, req) {
+  context.log("JavaScript HTTP trigger function processed a request.");
+
+  if (req.query.name || (req.body && req.body.name)) {
+    context.res = {
+      // status: 200, /* Defaults to 200 */
+      body: "${name} " + (req.query.name || req.body.name)
+    };
+  }
+  else {
+    context.res = {
+      status: 400,
+      body: "Please pass a name on the query string or in the request body"
+    };
+  }
+};`
+  }
+
+  public static getFunctionJsonString(name: string, options: any) {
+    // TODO: This is where we would just generate function JSON from SLS object
+    // using getFunctionSlsObject(name, options). Currently defaulting to http in and out
+    return JSON.stringify(httpBinding, null, 2);
+  }
+
+  public static getFunctionSlsObject(name: string, options: any) {
+    return FuncPluginUtils.defaultFunctionSlsObject(name);
+  }
+
+  private static defaultFunctionSlsObject(name: string) {
+    return {
+      handler: "index.handler",
+      events: FuncPluginUtils.httpEvents()
+    }
+  }  
+
+  private static httpEvents() {
+    return [
+      {
+        http: true,
+        "x-azure-settings": {
+          authLevel: "anonymous"
+        }
+      },
+      {
+        http: true,
+        "x-azure-settings": {
+          direction: "out",
+          name: "res"
+        }
+      },
+    ]
+  } 
+}
\ No newline at end of file
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
index 96ab9f0..ee4e3cf 100644
--- a/src/shared/utils.ts
+++ b/src/shared/utils.ts
@@ -1,3 +1,4 @@
+import Serverless from "serverless";
 import { BindingUtils } from "./bindings";
 import { constants } from "./constants";
 
@@ -110,4 +111,11 @@ export class Utils {
   
     return metaData;
   }
+
+  public static interpolateFile(sls: Serverless, path: string, params: Map<string, string>) {
+    const template = sls.utils.readFileSync(path);
+    const names = params.keys();
+    const vals = params.values();
+    return new Function(...names, `return \`${template}\`;`)(...vals);
+  }
 }


Test patch:
diff --git a/src/plugins/func/azureFunc.test.ts b/src/plugins/func/azureFunc.test.ts
new file mode 100644
index 0000000..2ff3252
--- /dev/null
+++ b/src/plugins/func/azureFunc.test.ts
@@ -0,0 +1,117 @@
+import fs from "fs";
+import mockFs from "mock-fs";
+import path from "path";
+import { MockFactory } from "../../test/mockFactory";
+import { invokeHook } from "../../test/utils";
+import { AzureFuncPlugin } from "./azureFunc";
+import rimraf from "rimraf";
+
+describe("Azure Func Plugin", () => {
+  
+  it("displays a help message", async () => {
+    const sls = MockFactory.createTestServerless();
+    const options = MockFactory.createTestServerlessOptions();
+    const plugin = new AzureFuncPlugin(sls, options);
+    await invokeHook(plugin, "func:func");
+    expect(sls.cli.log).toBeCalledWith("Use the func plugin to add or remove functions within Function App");
+  })
+
+  describe("Add command", () => {
+
+    beforeAll(() => {
+      mockFs({
+        "myExistingFunction": {
+          "index.js": "contents",
+          "function.json": "contents",
+        },
+        "serverless.yml": MockFactory.createTestServerlessYml(true),
+      }, {createCwd: true, createTmp: true})
+    });
+  
+    afterAll(() => {
+      mockFs.restore();
+    });
+
+    it("returns with missing name", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      const plugin = new AzureFuncPlugin(sls, options);
+      await invokeHook(plugin, "func:add:add");
+      expect(sls.cli.log).toBeCalledWith("Need to provide a name of function to add")
+    });
+  
+    it("returns with pre-existing function", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      options["name"] = "myExistingFunction";
+      const plugin = new AzureFuncPlugin(sls, options);
+      await invokeHook(plugin, "func:add:add");
+      expect(sls.cli.log).toBeCalledWith(`Function myExistingFunction already exists`);
+    });
+  
+    it("creates function directory and updates serverless.yml", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      const functionName = "myFunction";
+      options["name"] = functionName;
+      const plugin = new AzureFuncPlugin(sls, options);
+      const mkdirSpy = jest.spyOn(fs, "mkdirSync");
+      await invokeHook(plugin, "func:add:add");
+      expect(mkdirSpy).toBeCalledWith(functionName);
+      const calls = (sls.utils.writeFileSync as any).mock.calls;
+      expect(calls[0][0]).toBe(path.join(functionName, "index.js"));
+      expect(calls[1][0]).toBe(path.join(functionName, "function.json"));
+      const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata();
+      expectedFunctionsYml[functionName] = MockFactory.createTestFunctionMetadata();
+      expect(calls[2][0]).toBe("serverless.yml");
+      expect(calls[2][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml));
+    });
+  });
+
+  describe("Remove command", () => {  
+    
+    beforeAll(() => {
+      mockFs({
+        "function1": {
+          "index.js": "contents",
+          "function.json": "contents",
+        },
+      }, {createCwd: true, createTmp: true});    
+    });
+  
+    afterAll(() => {
+      mockFs.restore();
+    });
+  
+    it("returns with missing name", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      const plugin = new AzureFuncPlugin(sls, options);
+      await invokeHook(plugin, "func:remove:remove");
+      expect(sls.cli.log).toBeCalledWith("Need to provide a name of function to remove")
+    });
+  
+    it("returns with non-existing function", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      options["name"] = "myNonExistingFunction";
+      const plugin = new AzureFuncPlugin(sls, options);
+      await invokeHook(plugin, "func:remove:remove");
+      expect(sls.cli.log).toBeCalledWith(`Function myNonExistingFunction does not exist`);
+    });
+  
+    it("deletes directory and updates serverless.yml", async () => {
+      const sls = MockFactory.createTestServerless();
+      const options = MockFactory.createTestServerlessOptions();
+      const plugin = new AzureFuncPlugin(sls, options);
+      const functionName = "function1";
+      options["name"] = functionName;
+      const rimrafSpy = jest.spyOn(rimraf, "sync");
+      await invokeHook(plugin, "func:remove:remove");
+      expect(rimrafSpy).toBeCalledWith(functionName);
+      const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata();
+      delete expectedFunctionsYml[functionName];
+      expect(sls.utils.writeFileSync).toBeCalledWith("serverless.yml", MockFactory.createTestServerlessYml(true, expectedFunctionsYml))
+    });
+  });
+});
\ No newline at end of file
diff --git a/src/plugins/func/funcUtils.test.ts b/src/plugins/func/funcUtils.test.ts
new file mode 100644
index 0000000..8348045
--- /dev/null
+++ b/src/plugins/func/funcUtils.test.ts
@@ -0,0 +1,32 @@
+import { MockFactory } from "../../test/mockFactory";
+import { FuncPluginUtils } from "./funcUtils";
+
+describe("Func Utils", () => {
+  
+  it("gets functions yml", () => {
+    const sls = MockFactory.createTestServerless();
+    const funcYaml = FuncPluginUtils.getFunctionsYml(sls);
+    expect(funcYaml).toEqual(MockFactory.createTestFunctionsMetadata());
+  });
+
+  it("updates functions yml", () => {
+    const updatedFunctions = MockFactory.createTestFunctionsMetadata(3);
+    const originalSls = MockFactory.createTestServerlessYml(false, 2);
+    const sls = MockFactory.createTestServerless();
+    FuncPluginUtils.updateFunctionsYml(sls, updatedFunctions, originalSls);
+    const calls = (sls.utils.writeFileSync as any).mock.calls[0]
+    expect(calls[0]).toBe("serverless.yml");
+    const expected = MockFactory.createTestServerlessYml(
+      true, 
+      MockFactory.createTestFunctionsMetadata(3)
+    );
+    expect(calls[1]).toBe(expected);
+  });
+
+  it("adds new function name to function handler", () => {
+    const name = "This is my function name"
+    const handler = FuncPluginUtils.getFunctionHandler(name);
+    expect(handler)
+      .toContain(`body: "${name} " + (req.query.name || req.body.name)`);    
+  });
+});
\ No newline at end of file
diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts
index ae3e18c..5e3ad1a 100644
--- a/src/test/mockFactory.ts
+++ b/src/test/mockFactory.ts
@@ -1,4 +1,5 @@
 import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from "@azure/ms-rest-nodeauth";
+import yaml from "js-yaml";
 import Serverless from "serverless";
 import Service from "serverless/classes/Service";
 import Utils = require("serverless/classes/Utils");
@@ -44,6 +45,50 @@ export class MockFactory {
     }
   }
 
+  public static createTestServerlessYml(asYaml = false, functionMetadata?) {
+    const data = {
+      "provider": {
+        "name": "azure",
+        "location": "West US 2"
+      },
+      "plugins": [
+        "serverless-azure-functions"
+      ],
+      "functions": functionMetadata || MockFactory.createTestFunctionsMetadata(2, false),
+    }
+    return (asYaml) ? yaml.dump(data) : data;
+  }
+
+  public static createTestFunctionsMetadata(functionCount = 2, wrap = false) {
+    const data = {};
+    for (let i = 0; i < functionCount; i++) {
+      const functionName = `function${i+1}`;
+      data[functionName] = MockFactory.createTestFunctionMetadata()
+    }
+    return (wrap) ? {"functions": data } : data;
+  }
+
+  public static createTestFunctionMetadata() {
+    return {
+      "handler": "index.handler",
+      "events": [
+        {
+          "http": true,
+          "x-azure-settings": {
+            "authLevel": "anonymous"
+          }
+        },
+        {
+          "http": true,
+          "x-azure-settings": {
+            "direction": "out",
+            "name": "res"
+          }
+        }
+      ]
+    }
+  }
+
   public static createTestFunctionApp() {
     return {
       id: "App Id",
@@ -101,7 +146,11 @@ export class MockFactory {
       getVersion: jest.fn(),
       logStat: jest.fn(),
       readFile: jest.fn(),
-      readFileSync: jest.fn(),
+      readFileSync: jest.fn((filename) => {
+        if (filename === "serverless.yml") {
+          return MockFactory.createTestServerlessYml();
+        }
+      }),
       walkDirSync: jest.fn(),
       writeFile: jest.fn(),
       writeFileDir: jest.fn(),