Repository: serverless/serverless-azure-functions
Pull request #164 (2019-06-11 21:28:48)

feat: Adds webpack support for azure plugin
When azure serverless project is deploy with webpack we need to ensure that the required files for azure functions are part of the deployment package.

- Copies host.json & function.json files for each serverless function.


Patch:
diff --git a/src/plugins/package/azurePackage.ts b/src/plugins/package/azurePackage.ts
index dc839c2..37b5881 100644
--- a/src/plugins/package/azurePackage.ts
+++ b/src/plugins/package/azurePackage.ts
@@ -1,54 +1,44 @@
 
 import Serverless from "serverless";
 import AzureProvider from "../../provider/azureProvider";
-import { BindingUtils } from "../../shared/bindings";
-import { Utils } from "../../shared/utils";
-import fs from "fs";
-import path from "path";
+import { PackageService } from "../../services/packageService";
 
 export class AzurePackage {
+  private bindingsCreated: boolean = false;
+  private packageService: PackageService;
   public provider: AzureProvider;
   public hooks: { [eventName: string]: Promise<any> };
 
-  public constructor(private serverless: Serverless, private options: Serverless.Options) {
+  public constructor(private serverless: Serverless) {
     this.hooks = {
-      "package:setupProviderConfiguration": this.setupProviderConfiguration.bind(this),
-      "package:finalize": this.finalize.bind(this),
+      "before:package:setupProviderConfiguration": this.setupProviderConfiguration.bind(this),
+      "before:webpack:package:packageModules": this.webpack.bind(this),
+      "after:package:finalize": this.finalize.bind(this),
     };
+
+    this.packageService = new PackageService(this.serverless);
   }
 
-  private async setupProviderConfiguration(): Promise<any[]> {
-    this.serverless.cli.log("Building Azure Events Hooks");
+  private async setupProviderConfiguration(): Promise<void> {
+    await this.packageService.createBindings();
+    this.bindingsCreated = true;
 
-    const createEventsPromises = this.serverless.service.getAllFunctions()
-      .map((functionName) => {
-        const metaData = Utils.getFunctionMetaData(functionName, this.serverless);
+    return Promise.resolve();
+  }
 
-        return BindingUtils.createEventsBindings(this.serverless.config.servicePath, functionName, metaData);
-      });
+  private async webpack(): Promise<void> {
+    if (!this.bindingsCreated) {
+      await this.setupProviderConfiguration();
+    }
 
-    return Promise.all(createEventsPromises);
+    await this.packageService.prepareWebpack();
   }
 
   /**
    * Cleans up generated folders & files after packaging is complete
    */
-  private finalize(): Promise<void> {
-    this.serverless.service.getAllFunctions().map((functionName) => {
-      // Delete function.json if exists in function folder
-      const filePath = path.join(functionName, "function.json");
-      if (fs.existsSync(filePath)) {
-        fs.unlinkSync(filePath);
-      }
-
-      // Delete function folder if empty
-      const items = fs.readdirSync(functionName);
-      if (items.length === 0) {
-        fs.rmdirSync(functionName);
-      }
-    });
-
-    return Promise.resolve();
+  private async finalize(): Promise<void> {
+    await this.packageService.cleanUp();
   }
 }
 
diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts
index 8c71fa1..5ea7c7d 100644
--- a/src/services/functionAppService.ts
+++ b/src/services/functionAppService.ts
@@ -180,8 +180,12 @@ export class FunctionAppService extends BaseService {
     this.serverless.cli.log(`Deploying zip file to function app: ${functionAppName}`);
 
     // Upload function artifact if it exists, otherwise the full service is handled in 'uploadFunctions' method
-    const functionZipFile = this.serverless.service["artifact"];
+    let functionZipFile = this.serverless.service["artifact"];
     if (!functionZipFile) {
+      functionZipFile = path.join(this.serverless.config.servicePath, ".serverless", `${this.serverless.service.getServiceName()}.zip`);
+    }
+
+    if (!(functionZipFile && fs.existsSync(functionZipFile))) {
       throw new Error("No zip file found for function app");
     }
 
diff --git a/src/services/packageService.ts b/src/services/packageService.ts
new file mode 100644
index 0000000..1bcd943
--- /dev/null
+++ b/src/services/packageService.ts
@@ -0,0 +1,95 @@
+import Serverless from "serverless";
+import path from "path";
+import fs from "fs";
+import { Utils, FunctionMetadata } from "../shared/utils";
+
+/**
+ * Adds service packing support
+ */
+export class PackageService {
+  public constructor(private serverless: Serverless) {
+  }
+
+  /**
+   * Creates the function.json binding files required for the serverless service
+   */
+  public createBindings(): Promise<any[]> {
+    const createEventsPromises = this.serverless.service.getAllFunctions()
+      .map((functionName) => {
+        const metaData = Utils.getFunctionMetaData(functionName, this.serverless);
+        return this.createBinding(functionName, metaData);
+      });
+
+    return Promise.all(createEventsPromises);
+  }
+
+  /**
+   * Prepares a serverless project for webpack and copies required files including
+   * host.json and function.json files
+   */
+  public prepareWebpack() {
+    const filesToCopy: string[] = [];
+    if (fs.existsSync("host.json")) {
+      filesToCopy.push("host.json");
+    }
+
+    this.serverless.service.getAllFunctions().forEach((functionName) => {
+      const functionJsonPath = path.join(functionName, "function.json");
+      if (fs.existsSync(functionJsonPath)) {
+        filesToCopy.push(functionJsonPath);
+      }
+    });
+
+    this.serverless.cli.log("Copying files for webpack");
+    filesToCopy.forEach((filePath) => {
+      const destinationPath = path.join(".webpack", "service", filePath);
+      const destinationDirectory = path.dirname(destinationPath);
+      if (!fs.existsSync(destinationDirectory)) {
+        fs.mkdirSync(destinationDirectory);
+      }
+      fs.copyFileSync(filePath, destinationPath);
+      this.serverless.cli.log(`-> ${destinationPath}`);
+    });
+
+    return Promise.resolve();
+  }
+
+  /**
+   * Cleans up generated function.json files after packaging has completed
+   */
+  public cleanUp() {
+    this.serverless.service.getAllFunctions().map((functionName) => {
+      // Delete function.json if exists in function folder
+      const filePath = path.join(functionName, "function.json");
+      if (fs.existsSync(filePath)) {
+        fs.unlinkSync(filePath);
+      }
+
+      // Delete function folder if empty
+      const items = fs.readdirSync(functionName);
+      if (items.length === 0) {
+        fs.rmdirSync(functionName);
+      }
+    });
+
+    return Promise.resolve();
+  }
+
+  /**
+   * Creates the function.json for for the specified function
+   */
+  public createBinding(functionName: string, functionMetadata: FunctionMetadata) {
+    const functionJSON = functionMetadata.params.functionsJson;
+    functionJSON.entryPoint = functionMetadata.entryPoint;
+    functionJSON.scriptFile = functionMetadata.handlerPath;
+
+    const functionDirPath = path.join(this.serverless.config.servicePath, functionName);
+    if (!fs.existsSync(functionDirPath)) {
+      fs.mkdirSync(functionDirPath);
+    }
+
+    fs.writeFileSync(path.join(functionDirPath, "function.json"), JSON.stringify(functionJSON, null, 2));
+
+    return Promise.resolve();
+  }
+}
\ No newline at end of file
diff --git a/src/shared/bindings.ts b/src/shared/bindings.ts
index 0fa939c..7022b72 100644
--- a/src/shared/bindings.ts
+++ b/src/shared/bindings.ts
@@ -1,7 +1,4 @@
-import fs from "fs";
-import { join } from "path";
 import Serverless from "serverless";
-import { FunctionMetadata } from "./utils";
 import { constants } from "./constants";
 
 const bindingsJson = require("./bindings.json"); // eslint-disable-line @typescript-eslint/no-var-requires
@@ -37,21 +34,6 @@ export class BindingUtils {
     };
   }
 
-  public static createEventsBindings(servicePath: string, functionName: string, functionMetadata: FunctionMetadata): Promise<any> {
-    const functionJSON = functionMetadata.params.functionsJson;
-    functionJSON.entryPoint = functionMetadata.entryPoint;
-    functionJSON.scriptFile = functionMetadata.handlerPath;
-
-    const functionDirPath = join(servicePath, functionName);    
-    if (!fs.existsSync(functionDirPath)) {
-      fs.mkdirSync(functionDirPath);
-    }
-
-    fs.writeFileSync(join(functionDirPath, "function.json"), JSON.stringify(functionJSON, null, 2));
-
-    return Promise.resolve();
-  }
-
   public static getBindingUserSettingsMetaData(azureSettings, bindingType, bindingTypeIndex, bindingDisplayNames) {
     let bindingDisplayNamesIndex = bindingTypeIndex;
     const bindingUserSettings = {};
@@ -132,5 +114,4 @@ export class BindingUtils {
 
     return binding;
   }
-
 }


Test patch:
diff --git a/src/plugins/func/azureFunc.test.ts b/src/plugins/func/azureFunc.test.ts
index 66b5b4d..dfe7ab9 100644
--- a/src/plugins/func/azureFunc.test.ts
+++ b/src/plugins/func/azureFunc.test.ts
@@ -26,7 +26,7 @@ describe("Azure Func Plugin", () => {
           "index.js": "contents",
           "function.json": "contents",
         },
-        "serverless.yml": MockFactory.createTestServerlessYml(true),
+        "serverless.yml": MockFactory.createTestServerlessYml(true) as any,
       });
     });
 
diff --git a/src/plugins/login/loginPlugin.test.ts b/src/plugins/login/loginPlugin.test.ts
index 8b99775..6bf138e 100644
--- a/src/plugins/login/loginPlugin.test.ts
+++ b/src/plugins/login/loginPlugin.test.ts
@@ -89,4 +89,4 @@ describe("Login Plugin", () => {
     expect(AzureLoginService.servicePrincipalLogin).not.toBeCalled();
     expect(sls.cli.log).lastCalledWith(`Error: ${errorMessage}`)
   });
-})
\ No newline at end of file
+})
diff --git a/src/plugins/package/azurePackage.test.ts b/src/plugins/package/azurePackage.test.ts
index 5bdbc07..398a24d 100644
--- a/src/plugins/package/azurePackage.test.ts
+++ b/src/plugins/package/azurePackage.test.ts
@@ -1,107 +1,42 @@
+import Serverless from "serverless";
 import { MockFactory } from "../../test/mockFactory";
 import { invokeHook } from "../../test/utils";
 import { AzurePackage } from "./azurePackage";
-import fs from "fs";
-import mockFs from "mock-fs";
-
-jest.mock("../../shared/bindings");
-import { BindingUtils } from "../../shared/bindings";
-jest.mock("../../shared/utils");
-import { Utils } from "../../shared/utils";
+jest.mock("../../services/packageService");
+import { PackageService } from "../../services/packageService";
 
 describe("Azure Package Plugin", () => {
-  afterEach(() => {
-    mockFs.restore();
-  });
-
-  it("sets up provider configuration", async () => {
-    const slsFunctionConfig = MockFactory.createTestSlsFunctionConfig();
-    const sls = MockFactory.createTestServerless();
-
-    const functionConfig = Object.keys(slsFunctionConfig).map((funcName) => {
-      return {
-        name: funcName,
-        config: slsFunctionConfig[funcName],
-      };
-    });
-
-    Utils.getFunctionMetaData = jest.fn((funcName) => slsFunctionConfig[funcName]);
-    BindingUtils.createEventsBindings = jest.fn();
-
-    const options = MockFactory.createTestServerlessOptions();
-    const plugin = new AzurePackage(sls, options);
+  let sls: Serverless;
+  let plugin: AzurePackage;
 
-    await invokeHook(plugin, "package:setupProviderConfiguration");
+  beforeEach(() => {
+    jest.resetAllMocks();
+    sls = MockFactory.createTestServerless();
 
-    expect(sls.cli.log).toBeCalledWith("Building Azure Events Hooks");
-    expect(Utils.getFunctionMetaData).toBeCalledWith(functionConfig[0].name, sls);
-    expect(BindingUtils.createEventsBindings).toBeCalledWith(sls.config.servicePath, functionConfig[0].name, functionConfig[0].config);
+    plugin = new AzurePackage(sls);
   });
 
-  it("cleans up function.json and function folders", async () => {
-    const slsFunctionConfig = MockFactory.createTestSlsFunctionConfig();
-    const sls = MockFactory.createTestServerless();
-    Object.assign(sls.service, {
-      functions: slsFunctionConfig
-    });
-
-    const fsConfig = {};
-
-    const functionNames = sls.service.getAllFunctions();
-    functionNames.forEach((functionName) => {
-      fsConfig[functionName] = {
-        "function.json": "contents",
-      };
-    });
-
-    mockFs(fsConfig);
-
-    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
-    const rmdirSpy = jest.spyOn(fs, "rmdirSync");
-
-    const options = MockFactory.createTestServerlessOptions();
-    const plugin = new AzurePackage(sls, options);
-
-    await invokeHook(plugin, "package:finalize");
-
-    expect(unlinkSpy).toBeCalledTimes(functionNames.length);
-    expect(rmdirSpy).toBeCalledTimes(functionNames.length);
-
-    unlinkSpy.mockRestore();
-    rmdirSpy.mockRestore();
+  it("sets creates function bindings before package:setupProviderConfiguration life cycle event", async () => {
+    await invokeHook(plugin, "before:package:setupProviderConfiguration");
+    expect(PackageService.prototype.createBindings).toBeCalled();
   });
 
-  it("cleans up function.json but does not delete function folder", async () => {
-    const slsFunctionConfig = MockFactory.createTestSlsFunctionConfig();
-    const sls = MockFactory.createTestServerless();
-    Object.assign(sls.service, {
-      functions: slsFunctionConfig
-    });
-
-    const fsConfig = {};
-
-    const functionNames = sls.service.getAllFunctions();
-    functionNames.forEach((functionName) => {
-      fsConfig[functionName] = {
-        "function.json": "contents",
-        "index.js": "contents",
-      };
-    });
-
-    mockFs(fsConfig);
-
-    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
-    const rmdirSpy = jest.spyOn(fs, "rmdirSync");
-
-    const options = MockFactory.createTestServerlessOptions();
-    const plugin = new AzurePackage(sls, options);
-
-    await invokeHook(plugin, "package:finalize");
+  it("prepares the package for webpack before webpack:package:packageModules life cycle event", async () => {
+    await invokeHook(plugin, "before:webpack:package:packageModules");
+    expect(PackageService.prototype.createBindings).toBeCalled();
+    expect(PackageService.prototype.prepareWebpack).toBeCalled();
+  });
 
-    expect(unlinkSpy).toBeCalledTimes(functionNames.length);
-    expect(rmdirSpy).not.toBeCalled();
+  it("only calls create bindings 1 time throughout full package life cycle", async () => {
+    await invokeHook(plugin, "before:package:setupProviderConfiguration");
+    await invokeHook(plugin, "before:webpack:package:packageModules");
 
-    unlinkSpy.mockRestore();
-    rmdirSpy.mockRestore();
+    expect(PackageService.prototype.createBindings).toBeCalledTimes(1);
+    expect(PackageService.prototype.prepareWebpack).toBeCalledTimes(1);
   });
-});
\ No newline at end of file
+
+  it("cleans up package after package:finalize", async () => {
+    await invokeHook(plugin, "after:package:finalize");
+    expect(PackageService.prototype.cleanUp).toBeCalled();
+  })
+});
diff --git a/src/services/packageService.test.ts b/src/services/packageService.test.ts
new file mode 100644
index 0000000..4e06755
--- /dev/null
+++ b/src/services/packageService.test.ts
@@ -0,0 +1,165 @@
+import Serverless from "serverless";
+import mockFs from "mock-fs";
+import fs from "fs";
+import path from "path";
+import { PackageService } from "./packageService";
+import { MockFactory } from "../test/mockFactory";
+import { FunctionMetadata } from "../shared/utils";
+
+describe("Package Service", () => {
+  let sls: Serverless;
+  let packageService: PackageService;
+
+  beforeEach(() => {
+    sls = MockFactory.createTestServerless();
+    sls.config.servicePath = process.cwd();
+
+    packageService = new PackageService(sls);
+  });
+
+  afterEach(() => {
+    mockFs.restore();
+  });
+
+  it("cleans up function.json and function folders", async () => {
+    const fsConfig = {};
+    const functionNames = sls.service.getAllFunctions();
+
+    functionNames.forEach((functionName) => {
+      fsConfig[functionName] = {
+        "function.json": "contents",
+      };
+    });
+
+    mockFs(fsConfig);
+
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync");
+
+    packageService.cleanUp();
+
+    expect(unlinkSpy).toBeCalledTimes(functionNames.length);
+    expect(rmdirSpy).toBeCalledTimes(functionNames.length);
+
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+  });
+
+  it("cleans up function.json but does not delete function folder", async () => {
+    const fsConfig = {};
+
+    const functionNames = sls.service.getAllFunctions();
+    functionNames.forEach((functionName) => {
+      fsConfig[functionName] = {
+        "function.json": "contents",
+        "index.js": "contents",
+      };
+    });
+
+    mockFs(fsConfig);
+
+    const unlinkSpy = jest.spyOn(fs, "unlinkSync");
+    const rmdirSpy = jest.spyOn(fs, "rmdirSync");
+
+    packageService.cleanUp();
+
+    expect(unlinkSpy).toBeCalledTimes(functionNames.length);
+    expect(rmdirSpy).not.toBeCalled();
+
+    unlinkSpy.mockRestore();
+    rmdirSpy.mockRestore();
+  });
+
+  it("createBinding writes function.json files into function folder", async () => {
+    const functionName = "helloWorld";
+    const functionMetadata: FunctionMetadata = {
+      entryPoint: "handler",
+      handlerPath: "src/handlers/hello.js",
+      params: {
+        functionsJson: {},
+      },
+    };
+
+    const expectedFolderPath = path.join(sls.config.servicePath, functionName);
+    const expectedFilePath = path.join(expectedFolderPath, "function.json");
+
+    mockFs({});
+
+    const mkdirSpy = jest.spyOn(fs, "mkdirSync");
+    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
+
+    await packageService.createBinding(functionName, functionMetadata);
+
+    expect(mkdirSpy).toBeCalledWith(expectedFolderPath);
+    expect(writeFileSpy).toBeCalledWith(expectedFilePath, expect.any(String));
+
+    mkdirSpy.mockRestore();
+    writeFileSpy.mockRestore();
+  });
+
+  it("createBinding does not need to create directory if function folder already exists", async () => {
+    const functionName = "helloWorld";
+    const functionMetadata: FunctionMetadata = {
+      entryPoint: "handler",
+      handlerPath: "src/handlers/hello.js",
+      params: {
+        functionsJson: {},
+      },
+    };
+
+    const expectedFolderPath = path.join(sls.config.servicePath, functionName);
+    const expectedFilePath = path.join(expectedFolderPath, "function.json");
+
+    mockFs({
+      "helloWorld": {
+        "index.js": "contents",
+      },
+    });
+
+    sls.utils.dirExistsSync = jest.fn(() => true);
+    const mkdirSpy = jest.spyOn(fs, "mkdirSync");
+    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
+
+    await packageService.createBinding(functionName, functionMetadata);
+
+    expect(mkdirSpy).not.toBeCalledWith(expectedFolderPath);
+    expect(writeFileSpy).toBeCalledWith(expectedFilePath, expect.any(String));
+
+    mkdirSpy.mockRestore();
+    writeFileSpy.mockRestore();
+  });
+
+  it("webpack copies required", async () => {
+    mockFs({
+      // Generated by webpack plugin
+      ".webpack": {
+        "serivce": {}
+      },
+      // Generated by azure package plugin
+      "host.json": "contents",
+      "hello": {
+        "function.json": "contents",
+      },
+      "goodbye": {
+        "function.json": "contents",
+      },
+    });
+
+    const destinationPath = path.join(".webpack", "service");
+
+    const mkDirSpy = jest.spyOn(fs, "mkdirSync");
+    const copyFileSpy = jest.spyOn(fs, "copyFileSync");
+
+    await packageService.prepareWebpack();
+
+    expect(copyFileSpy).toBeCalledWith("host.json", path.join(destinationPath, "host.json"));
+
+    sls.service.getAllFunctions().forEach((functionName) => {
+      const functionJsonFilePath = path.join(functionName, "function.json");
+      expect(copyFileSpy).toBeCalledWith(functionJsonFilePath, path.join(destinationPath, functionJsonFilePath));
+    });
+
+    mkDirSpy.mockRestore();
+    copyFileSpy.mockRestore();
+  });
+});
\ No newline at end of file
diff --git a/src/shared/binding.test.ts b/src/shared/binding.test.ts
index 21cc907..c78e19b 100644
--- a/src/shared/binding.test.ts
+++ b/src/shared/binding.test.ts
@@ -1,9 +1,6 @@
 import Serverless from "serverless";
 import { MockFactory } from "../test/mockFactory";
 import { BindingUtils } from "./bindings";
-import { FunctionMetadata } from "./utils";
-import path from "path";
-import fs from "fs";
 import mockFs from "mock-fs";
 
 describe("Bindings", () => {
@@ -23,63 +20,4 @@ describe("Bindings", () => {
     BindingUtils.getBindingsMetaData(sls);
     expect(sls.cli.log).toBeCalledWith("Parsing Azure Functions Bindings.json...");
   });
-
-  it("createEventsBindings writes function.json files into function folder", async () => {
-    const functionName = "helloWorld";
-    const functionMetadata: FunctionMetadata = {
-      entryPoint: "handler",
-      handlerPath: "src/handlers/hello.js",
-      params: {
-        functionsJson: {},
-      },
-    };
-
-    const expectedFolderPath = path.join(sls.config.servicePath, functionName);
-    const expectedFilePath = path.join(expectedFolderPath, "function.json");
-
-    mockFs({});
-
-    const mkdirSpy = jest.spyOn(fs, "mkdirSync");
-    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
-
-    await BindingUtils.createEventsBindings(sls.config.servicePath, functionName, functionMetadata);
-
-    expect(mkdirSpy).toBeCalledWith(expectedFolderPath);
-    expect(writeFileSpy).toBeCalledWith(expectedFilePath, expect.any(String));
-
-    mkdirSpy.mockRestore();
-    writeFileSpy.mockRestore();
-  });
-
-  it("does not need to create directory if function folder already exists", async () => {
-    const functionName = "helloWorld";
-    const functionMetadata: FunctionMetadata = {
-      entryPoint: "handler",
-      handlerPath: "src/handlers/hello.js",
-      params: {
-        functionsJson: {},
-      },
-    };
-
-    const expectedFolderPath = path.join(sls.config.servicePath, functionName);
-    const expectedFilePath = path.join(expectedFolderPath, "function.json");
-
-    mockFs({
-      "helloWorld": {
-        "index.js": "contents",
-      },
-    });
-
-    sls.utils.dirExistsSync = jest.fn(() => true);
-    const mkdirSpy = jest.spyOn(fs, "mkdirSync");
-    const writeFileSpy = jest.spyOn(fs, "writeFileSync");
-
-    await BindingUtils.createEventsBindings(sls.config.servicePath, functionName, functionMetadata);
-
-    expect(mkdirSpy).not.toBeCalledWith(expectedFolderPath);
-    expect(writeFileSpy).toBeCalledWith(expectedFilePath, expect.any(String));
-
-    mkdirSpy.mockRestore();
-    writeFileSpy.mockRestore();
-  });
 });