diff --git a/package-lock.json b/package-lock.json index 025338f7..04d35d85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6794,9 +6794,9 @@ "dev": true }, "nan": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz", - "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", "optional": true }, "nanomatch": { diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 8b3dc442..00000000 --- a/src/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ - -describe("Index test", () => { - it("should run a test", () => { - expect(true).toBe(true); - }); -}); \ No newline at end of file diff --git a/src/plugins/apim/apimFunctionPlugin.test.ts b/src/plugins/apim/apimFunctionPlugin.test.ts new file mode 100644 index 00000000..d7a02afd --- /dev/null +++ b/src/plugins/apim/apimFunctionPlugin.test.ts @@ -0,0 +1,25 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureApimFunctionPlugin } from './apimFunctionPlugin'; + +jest.mock('../../services/apimService'); +import { ApimService } from '../../services/apimService'; + +describe('APIM Function Plugin', () => { + it('calls deploy function', async () => { + const deployFunction = jest.fn(); + + ApimService.prototype.deployFunction = deployFunction; + + const sls = MockFactory.createTestServerless(); + sls.service.provider['apim'] = 'apim config' + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureApimFunctionPlugin(sls, options); + + await invokeHook(plugin, 'after:deploy:function:deploy'); + + expect(sls.cli.log).toBeCalledWith('Starting APIM function deployment') + expect(deployFunction).toBeCalled(); + expect(sls.cli.log).lastCalledWith('Finished APIM function deployment') + }); +}); \ No newline at end of file diff --git a/src/plugins/apim/apimServicePlugin.test.ts b/src/plugins/apim/apimServicePlugin.test.ts index a4075947..c17a705d 100644 --- a/src/plugins/apim/apimServicePlugin.test.ts +++ b/src/plugins/apim/apimServicePlugin.test.ts @@ -1,6 +1,11 @@ import Serverless from 'serverless'; +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; import { AzureApimServicePlugin } from './apimServicePlugin'; +jest.mock('../../services/apimService'); +import { ApimService } from '../../services/apimService'; + describe('APIM Service Plugin', () => { it('is defined', () => { expect(AzureApimServicePlugin).toBeDefined(); @@ -16,4 +21,42 @@ describe('APIM Service Plugin', () => { expect(plugin).not.toBeNull(); }); + + it('calls deploy API and deploy functions', async () => { + const deployApi = jest.fn(); + const deployFunctions = jest.fn(); + + ApimService.prototype.deployApi = deployApi; + ApimService.prototype.deployFunctions = deployFunctions; + + const sls = MockFactory.createTestServerless(); + sls.service.provider['apim'] = 'apim config' + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureApimServicePlugin(sls, options); + + await invokeHook(plugin, 'after:deploy:deploy'); + + expect(sls.cli.log).toBeCalledWith('Starting APIM service deployment') + expect(deployApi).toBeCalled(); + expect(deployFunctions).toBeCalled(); + expect(sls.cli.log).lastCalledWith('Finished APIM service deployment') + }); + + it('does not call deploy API or deploy functions when "apim" not included in config', async () => { + const deployApi = jest.fn(); + const deployFunctions = jest.fn(); + + ApimService.prototype.deployApi = deployApi; + ApimService.prototype.deployFunctions = deployFunctions; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureApimServicePlugin(sls, options); + + await invokeHook(plugin, 'after:deploy:deploy'); + + expect(sls.cli.log).not.toBeCalled() + expect(deployApi).not.toBeCalled(); + expect(deployFunctions).not.toBeCalled(); + }); }); \ No newline at end of file diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts new file mode 100644 index 00000000..05d3d2f5 --- /dev/null +++ b/src/plugins/deploy/azureDeployPlugin.test.ts @@ -0,0 +1,33 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureDeployPlugin } from "./azureDeployPlugin"; + +jest.mock("../../services/functionAppService"); +import { FunctionAppService } from "../../services/functionAppService"; + +jest.mock("../../services/resourceService"); +import { ResourceService } from "../../services/resourceService"; + +describe('Deploy plugin', () => { + + it('calls deploy hook', async () => { + const deployResourceGroup = jest.fn(); + const functionAppStub = "Function App Stub"; + const deploy = jest.fn(() => Promise.resolve(functionAppStub)); + const uploadFunctions = jest.fn(); + + ResourceService.prototype.deployResourceGroup = deployResourceGroup + FunctionAppService.prototype.deploy = deploy + FunctionAppService.prototype.uploadFunctions = uploadFunctions + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureDeployPlugin(sls, options); + + await invokeHook(plugin, 'deploy:deploy'); + + expect(deployResourceGroup).toBeCalled(); + expect(deploy).toBeCalled(); + expect(uploadFunctions).toBeCalledWith(functionAppStub); + }); +}); \ No newline at end of file diff --git a/src/plugins/login/loginPlugin.test.ts b/src/plugins/login/loginPlugin.test.ts new file mode 100644 index 00000000..86f9eea4 --- /dev/null +++ b/src/plugins/login/loginPlugin.test.ts @@ -0,0 +1,123 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureLoginPlugin } from "./loginPlugin"; +import { AzureLoginService } from "../../services/loginService"; + +describe('Login Plugin', () => { + + const authResponse = MockFactory.createTestAuthResponse(); + + it('returns if azure credentials are set', async () => { + const interactiveLogin = jest.fn(() => Promise.resolve(authResponse)); + const servicePrincipalLogin = jest.fn(() => Promise.resolve(authResponse)); + + AzureLoginService.interactiveLogin = interactiveLogin; + AzureLoginService.servicePrincipalLogin = servicePrincipalLogin; + + const sls = MockFactory.createTestServerless(); + sls.variables['azureCredentials'] = 'credentials'; + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureLoginPlugin(sls, options); + + await invokeHook(plugin, 'before:package:initialize'); + + expect(interactiveLogin).not.toBeCalled(); + expect(servicePrincipalLogin).not.toBeCalled(); + }); + + it('calls login if azure credentials are not set', async () => { + const interactiveLogin = jest.fn(() => Promise.resolve(authResponse)); + const servicePrincipalLogin = jest.fn(() => Promise.resolve(authResponse)); + + AzureLoginService.interactiveLogin = interactiveLogin; + AzureLoginService.servicePrincipalLogin = servicePrincipalLogin; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureLoginPlugin(sls, options); + + await invokeHook(plugin, 'before:package:initialize'); + + expect(interactiveLogin).toBeCalled(); + expect(servicePrincipalLogin).not.toBeCalled(); + }); + + it('calls service principal login if environment variables are set', async () => { + + process.env.azureSubId = 'azureSubId'; + process.env.azureServicePrincipalClientId = 'azureServicePrincipalClientId'; + process.env.azureServicePrincipalPassword = 'azureServicePrincipalPassword'; + process.env.azureServicePrincipalTenantId = 'azureServicePrincipalTenantId'; + + const interactiveLogin = jest.fn(() => Promise.resolve(authResponse)); + const servicePrincipalLogin = jest.fn(() => Promise.resolve(authResponse)); + + AzureLoginService.interactiveLogin = interactiveLogin; + AzureLoginService.servicePrincipalLogin = servicePrincipalLogin; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureLoginPlugin(sls, options); + await invokeHook(plugin, 'before:package:initialize'); + expect(servicePrincipalLogin).toBeCalledWith( + 'azureServicePrincipalClientId', + 'azureServicePrincipalPassword', + 'azureServicePrincipalTenantId' + ) + expect(interactiveLogin).not.toBeCalled(); + + expect(sls.variables['azureCredentials']).toEqual(authResponse.credentials); + expect(sls.variables['subscriptionId']).toEqual('azureSubId'); + }); + + it('calls interactive login if environment variables are not set', async () => { + delete process.env.azureSubId; + delete process.env.azureServicePrincipalClientId; + delete process.env.azureServicePrincipalPassword; + delete process.env.azureServicePrincipalTenantId; + + const interactiveLogin = jest.fn(() => Promise.resolve(authResponse)); + const servicePrincipalLogin = jest.fn(() => Promise.resolve(authResponse)); + + AzureLoginService.interactiveLogin = interactiveLogin; + AzureLoginService.servicePrincipalLogin = servicePrincipalLogin; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureLoginPlugin(sls, options); + await invokeHook(plugin, 'before:package:initialize'); + expect(servicePrincipalLogin).not.toBeCalled(); + expect(interactiveLogin).toBeCalled(); + + expect(sls.variables['azureCredentials']).toEqual(authResponse.credentials); + expect(sls.variables['subscriptionId']).toEqual('azureSubId'); + }); + + it('logs an error from authentication', async () => { + process.env.azureSubId = 'azureSubId'; + process.env.azureServicePrincipalClientId = 'azureServicePrincipalClientId'; + process.env.azureServicePrincipalPassword = 'azureServicePrincipalPassword'; + process.env.azureServicePrincipalTenantId = 'azureServicePrincipalTenantId'; + + const interactiveLogin = jest.fn(() => Promise.resolve(authResponse)); + const errorMessage = 'This is my error message'; + const servicePrincipalLogin = jest.fn(() => { + throw new Error(errorMessage); + }); + + AzureLoginService.interactiveLogin = interactiveLogin; + AzureLoginService.servicePrincipalLogin = servicePrincipalLogin; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureLoginPlugin(sls, options); + await invokeHook(plugin, 'before:package:initialize'); + expect(servicePrincipalLogin).toBeCalledWith( + 'azureServicePrincipalClientId', + 'azureServicePrincipalPassword', + 'azureServicePrincipalTenantId' + ) + expect(interactiveLogin).not.toBeCalled(); + expect(sls.cli.log).lastCalledWith(`Error: ${errorMessage}`) + }); +}) \ No newline at end of file diff --git a/src/plugins/login/loginPlugin.ts b/src/plugins/login/loginPlugin.ts index e2425e2a..75072d35 100644 --- a/src/plugins/login/loginPlugin.ts +++ b/src/plugins/login/loginPlugin.ts @@ -1,7 +1,6 @@ -import open from 'open'; -import { interactiveLoginWithAuthResponse, loginWithServicePrincipalSecretWithAuthResponse } from '@azure/ms-rest-nodeauth'; import Serverless from 'serverless'; import AzureProvider from '../../provider/azureProvider'; +import { AzureLoginService } from '../../services/loginService'; export class AzureLoginPlugin { private provider: AzureProvider; @@ -23,32 +22,16 @@ export class AzureLoginPlugin { this.serverless.cli.log('Logging into Azure'); - let authResult = null; - - const subscriptionId = process.env.azureSubId; - const clientId = process.env.azureServicePrincipalClientId; - const secret = process.env.azureServicePrincipalPassword; - const tenantId = process.env.azureServicePrincipalTenantId; - try { - if (subscriptionId && clientId && secret && tenantId) { - authResult = await loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId); - } - else { - await open('https://microsoft.com/devicelogin'); - authResult = await interactiveLoginWithAuthResponse(); - } - - // TODO: This is temporary until the azure provider goes away - this.provider.credentials = authResult.credentials; - - this.serverless.variables['azureAccessToken'] = authResult.credentials.tokenCache._entries[0].accessToken; + const authResult = await AzureLoginService.login(); this.serverless.variables['azureCredentials'] = authResult.credentials; - this.serverless.variables['subscriptionId'] = authResult.subscriptionId || subscriptionId; + // Use environment variable for sub ID or use the first subscription in the list (service principal can + // have access to more than one subscription) + this.serverless.variables['subscriptionId'] = process.env.azureSubId || authResult.subscriptions[0].id; } catch (e) { this.serverless.cli.log('Error logging into azure'); - this.serverless.cli.log(e); + this.serverless.cli.log(`${e}`); } } } \ No newline at end of file diff --git a/src/plugins/package/azurePackage.test.ts b/src/plugins/package/azurePackage.test.ts new file mode 100644 index 00000000..1c18d650 --- /dev/null +++ b/src/plugins/package/azurePackage.test.ts @@ -0,0 +1,31 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzurePackage } from "./azurePackage" + +jest.mock('../../shared/bindings'); +import { BindingUtils } from '../../shared/bindings'; +jest.mock('../../shared/utils'); +import { Utils, FunctionMetadata } from '../../shared/utils'; + +describe('Azure Package Plugin', () => { + it('sets up provider configuration', async () => { + const metadata = 'metadata'; + const functionName = 'function1'; + + const getFunctionMetaDataFn = jest.fn(() => metadata as any as FunctionMetadata); + const createEventsBindingsFn = jest.fn(); + + Utils.getFunctionMetaData = getFunctionMetaDataFn + BindingUtils.createEventsBindings = createEventsBindingsFn + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzurePackage(sls, options); + + await invokeHook(plugin, 'package:setupProviderConfiguration'); + + expect(sls.cli.log).toBeCalledWith('Building Azure Events Hooks'); + expect(getFunctionMetaDataFn).toBeCalledWith(functionName, sls); + expect(createEventsBindingsFn).toBeCalledWith(sls.config.servicePath, functionName, metadata); + }); +}); \ No newline at end of file diff --git a/src/plugins/package/azurePackage.ts b/src/plugins/package/azurePackage.ts index 7d44762d..1eb4446f 100644 --- a/src/plugins/package/azurePackage.ts +++ b/src/plugins/package/azurePackage.ts @@ -1,8 +1,8 @@ import Serverless from 'serverless'; import AzureProvider from '../../provider/azureProvider'; -import { createEventsBindings } from '../../shared/bindings'; -import { getFunctionMetaData } from '../../shared/utils'; +import { BindingUtils } from '../../shared/bindings'; +import { Utils } from '../../shared/utils'; export class AzurePackage { provider: AzureProvider @@ -19,9 +19,9 @@ export class AzurePackage { const createEventsPromises = this.serverless.service.getAllFunctions() .map((functionName) => { - const metaData = getFunctionMetaData(functionName, this.serverless); + const metaData = Utils.getFunctionMetaData(functionName, this.serverless); - return createEventsBindings(this.serverless.config.servicePath, functionName, metaData); + return BindingUtils.createEventsBindings(this.serverless.config.servicePath, functionName, metaData); }); return Promise.all(createEventsPromises); diff --git a/src/plugins/remove/azureRemove.test.ts b/src/plugins/remove/azureRemove.test.ts new file mode 100644 index 00000000..223531f8 --- /dev/null +++ b/src/plugins/remove/azureRemove.test.ts @@ -0,0 +1,26 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureRemove } from "./azureRemove"; + +jest.mock("../../services/resourceService"); +import { ResourceService } from "../../services/resourceService"; + +describe('Remove Plugin', () => { + it('calls remove hook', async () => { + const deleteDeployment = jest.fn(); + const deleteResourceGroup = jest.fn(); + + ResourceService.prototype.deleteDeployment = deleteDeployment; + ResourceService.prototype.deleteResourceGroup = deleteResourceGroup; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const plugin = new AzureRemove(sls, options); + + await invokeHook(plugin, 'remove:remove'); + + expect(deleteDeployment).toBeCalled(); + expect(deleteResourceGroup).toBeCalled(); + expect(sls.cli.log).toBeCalledWith('Service successfully removed'); + }); +}); \ No newline at end of file diff --git a/src/services/loginService.ts b/src/services/loginService.ts new file mode 100644 index 00000000..1faa24b4 --- /dev/null +++ b/src/services/loginService.ts @@ -0,0 +1,26 @@ +import { interactiveLoginWithAuthResponse, loginWithServicePrincipalSecretWithAuthResponse } from '@azure/ms-rest-nodeauth'; + +export class AzureLoginService { + + public static async login() { + const subscriptionId = process.env.azureSubId; + const clientId = process.env.azureServicePrincipalClientId; + const secret = process.env.azureServicePrincipalPassword; + const tenantId = process.env.azureServicePrincipalTenantId; + + if (subscriptionId && clientId && secret && tenantId) { + return await AzureLoginService.servicePrincipalLogin(clientId, secret, tenantId); + } else { + return await AzureLoginService.interactiveLogin(); + } + } + + public static async interactiveLogin() { + await open('https://microsoft.com/devicelogin'); + return await interactiveLoginWithAuthResponse(); + } + + public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string) { + return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId); + } +} \ No newline at end of file diff --git a/src/shared/binding.test.ts b/src/shared/binding.test.ts index ea2c5758..8ea864a1 100644 --- a/src/shared/binding.test.ts +++ b/src/shared/binding.test.ts @@ -1,11 +1,11 @@ import { MockFactory } from '../test/mockFactory'; -import { getBindingsMetaData, createEventsBindings } from './bindings' +import { BindingUtils } from './bindings'; describe('Bindings', () => { it('should get bindings metadata from serverless', () => { const sls = MockFactory.createTestServerless(); expect(sls).not.toBeNull(); - const bindingsMetadata = getBindingsMetaData(sls); + BindingUtils.getBindingsMetaData(sls); expect(sls.cli.log).toBeCalledWith('Parsing Azure Functions Bindings.json...'); }); }); \ No newline at end of file diff --git a/src/shared/bindings.ts b/src/shared/bindings.ts index a4c70c61..c108072f 100644 --- a/src/shared/bindings.ts +++ b/src/shared/bindings.ts @@ -2,50 +2,128 @@ import { writeFileSync } from 'fs'; import { join } from 'path'; import Serverless from 'serverless'; import { FunctionMetadata } from './utils'; -const bindingsJson = require('./bindings.json'); - -const constants = { - bindings: 'bindings', - settings: 'settings', - name: 'name', - displayName: 'displayName', - type: 'type' -}; +import { constants } from './constants'; -export function getBindingsMetaData(serverless: Serverless) { - const bindingDisplayNames = []; - const bindingTypes = []; - const bindingSettings = []; - const bindingSettingsNames = []; - - serverless.cli.log('Parsing Azure Functions Bindings.json...'); +const bindingsJson = require('./bindings.json'); - for (let bindingsIndex = 0; bindingsIndex < bindingsJson[constants.bindings].length; bindingsIndex++) { - const settingsNames = []; +export class BindingUtils { + public static getBindingsMetaData(serverless: Serverless) { + const bindingDisplayNames = []; + const bindingTypes = []; + const bindingSettings = []; + const bindingSettingsNames = []; + + serverless.cli.log('Parsing Azure Functions Bindings.json...'); + + for (let bindingsIndex = 0; bindingsIndex < bindingsJson[constants.bindings].length; bindingsIndex++) { + const settingsNames = []; + + bindingTypes.push(bindingsJson[constants.bindings][bindingsIndex][constants.type]); + bindingDisplayNames.push(bindingsJson[constants.bindings][bindingsIndex][constants.displayName].toLowerCase()); + bindingSettings[bindingsIndex] = bindingsJson[constants.bindings][bindingsIndex][constants.settings]; + + for (let bindingSettingsIndex = 0; bindingSettingsIndex < bindingSettings[bindingsIndex].length; bindingSettingsIndex++) { + settingsNames.push(bindingSettings[bindingsIndex][bindingSettingsIndex][constants.name]); + } + + bindingSettingsNames[bindingsIndex] = settingsNames; + } + + return { + bindingDisplayNames: bindingDisplayNames, + bindingTypes: bindingTypes, + bindingSettings: bindingSettings, + bindingSettingsNames: bindingSettingsNames + }; + } - bindingTypes.push(bindingsJson[constants.bindings][bindingsIndex][constants.type]); - bindingDisplayNames.push(bindingsJson[constants.bindings][bindingsIndex][constants.displayName].toLowerCase()); - bindingSettings[bindingsIndex] = bindingsJson[constants.bindings][bindingsIndex][constants.settings]; + public static createEventsBindings(servicePath: string, functionName: string, functionMetadata: FunctionMetadata): Promise { + const functionJSON = functionMetadata.params.functionsJson; + functionJSON.entryPoint = functionMetadata.entryPoint; + functionJSON.scriptFile = functionMetadata.handlerPath; + writeFileSync(join(servicePath, functionName, 'function.json'), JSON.stringify(functionJSON, null, 4)); + return Promise.resolve(); + } - for (let bindingSettingsIndex = 0; bindingSettingsIndex < bindingSettings[bindingsIndex].length; bindingSettingsIndex++) { - settingsNames.push(bindingSettings[bindingsIndex][bindingSettingsIndex][constants.name]); + public static getBindingUserSettingsMetaData(azureSettings, bindingType, bindingTypeIndex, bindingDisplayNames) { + let bindingDisplayNamesIndex = bindingTypeIndex; + const bindingUserSettings = {}; + + if (azureSettings) { + const directionIndex = Object.keys(azureSettings).indexOf(constants.direction); + + if (directionIndex >= 0) { + const key = Object.keys(azureSettings)[directionIndex]; + const displayName = `$${bindingType}${azureSettings[key]}_displayName`; + + bindingDisplayNamesIndex = bindingDisplayNames.indexOf(displayName.toLowerCase()); + bindingUserSettings[constants.direction] = azureSettings[key]; + } } + const bindingUserSettingsMetaData = { + index: bindingDisplayNamesIndex, + userSettings: bindingUserSettings + }; + + return bindingUserSettingsMetaData; + } - bindingSettingsNames[bindingsIndex] = settingsNames; + public static getHttpOutBinding(bindingUserSettings) { + const binding = {}; + + binding[constants.type] = 'http'; + binding[constants.direction] = constants.outDirection; + binding[constants.name] = '$return'; + if (bindingUserSettings[constants.webHookType]) { + binding[constants.name] = 'res'; + } + + return binding; } - return { - bindingDisplayNames: bindingDisplayNames, - bindingTypes: bindingTypes, - bindingSettings: bindingSettings, - bindingSettingsNames: bindingSettingsNames - }; + public static getBinding(bindingType, bindingSettings, bindingUserSettings) { + const binding = {}; + + binding[constants.type] = bindingType; + if (bindingUserSettings && bindingUserSettings[constants.direction]) { + binding[constants.direction] = bindingUserSettings[constants.direction]; + } else if (bindingType.includes(constants.trigger)) { + binding[constants.direction] = constants.inDirection; + } else { + binding[constants.direction] = constants.outDirection; + } + + for (let bindingSettingsIndex = 0; bindingSettingsIndex < bindingSettings.length; bindingSettingsIndex++) { + const name = bindingSettings[bindingSettingsIndex][constants.name]; + + if (bindingUserSettings && bindingUserSettings[name] !== undefined && bindingUserSettings[name] !== null) { + binding[name] = bindingUserSettings[name]; + continue; + } + const value = bindingSettings[bindingSettingsIndex][constants.value]; + const required = bindingSettings[bindingSettingsIndex][constants.required]; + const resource = bindingSettings[bindingSettingsIndex][constants.resource]; + + if (required) { + const defaultValue = bindingSettings[bindingSettingsIndex][constants.defaultValue]; + + if (defaultValue) { + binding[name] = defaultValue; + } else if (name === constants.connection && resource.toLowerCase() === constants.storage) { + binding[name] = 'AzureWebJobsStorage'; + } else { + throw new Error(`Required property ${name} is missing for binding:${bindingType}`); + } + } + + if (value === constants.enum && name !== constants.webHookType) { + const enumValues = bindingSettings[bindingSettingsIndex][constants.enum]; + + binding[name] = enumValues[0][constants.value]; + } + } + + return binding; + } + } - -export async function createEventsBindings(servicePath: string, functionName: string, functionMetadata: FunctionMetadata): Promise { - const functionJSON = functionMetadata.params.functionsJson; - functionJSON.entryPoint = functionMetadata.entryPoint; - functionJSON.scriptFile = functionMetadata.handlerPath; - writeFileSync(join(servicePath, functionName, 'function.json'), JSON.stringify(functionJSON, null, 4)); - return Promise.resolve(); -} \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 00000000..a9dd3512 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,24 @@ +export const constants = { + bindings: 'bindings', + settings: 'settings', + name: 'name', + displayName: 'displayName', + type: 'type', + direction: 'direction', + trigger: 'Trigger', + inDirection: 'in', + outDirection: 'out', + value: 'value', + resource: 'resource', + required: 'required', + storage: 'storage', + connection: 'connection', + enum: 'enum', + defaultValue: 'defaultValue', + webHookType: 'webHookType', + httpTrigger: 'httpTrigger', + queue: 'queue', + queueName: 'queueName', + xAzureSettings: 'x-azure-settings', + entryPoint: 'entryPoint' +} \ No newline at end of file diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 05cd0351..55234686 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,28 +1,5 @@ -import { getBindingsMetaData } from "./bindings"; - -const constants = { - type: 'type', - direction: 'direction', - trigger: 'Trigger', - inDirection: 'in', - outDirection: 'out', - settings: 'settings', - name: 'name', - value: 'value', - resource: 'resource', - required: 'required', - storage: 'storage', - connection: 'connection', - enum: 'enum', - defaultValue: 'defaultValue', - webHookType: 'webHookType', - httpTrigger: 'httpTrigger', - queue: 'queue', - queueName: 'queueName', - displayName: 'displayName', - xAzureSettings: 'x-azure-settings', - entryPoint: 'entryPoint' -}; +import { BindingUtils } from "./bindings"; +import { constants } from "./constants"; export interface FunctionMetadata { entryPoint: any; @@ -30,186 +7,107 @@ export interface FunctionMetadata { params: any; } -export function getFunctionMetaData(functionName, serverless): FunctionMetadata { - const bindings = []; - let bindingSettingsNames = []; - let bindingSettings = []; - let bindingUserSettings = {}; - let bindingType; - const functionsJson = { disabled: false, bindings: [] }; - const functionObject = serverless.service.getFunction(functionName); - const handler = functionObject.handler; - const events = functionObject.events; - const params: any = { - functionJson: null - }; - - const parsedBindings = getBindingsMetaData(serverless); - - const bindingTypes = parsedBindings.bindingTypes; - const bindingDisplayNames = parsedBindings.bindingDisplayNames; - - for (let eventsIndex = 0; eventsIndex < events.length; eventsIndex++) { - bindingType = Object.keys(functionObject.events[eventsIndex])[0]; - - if (eventsIndex === 0) { - bindingType += constants.trigger; - } - - const index = bindingTypes.indexOf(bindingType); - - if (index < 0) { - throw new Error(`Binding ${bindingType} not supported`); - } - - serverless.cli.log(`Building binding for function: ${functionName} event: ${bindingType}`); - - bindingUserSettings = {}; - const azureSettings = events[eventsIndex][constants.xAzureSettings]; - let bindingTypeIndex = bindingTypes.indexOf(bindingType); - const bindingUserSettingsMetaData = this.getBindingUserSettingsMetaData(azureSettings, bindingType, bindingTypeIndex, bindingDisplayNames); - - bindingTypeIndex = bindingUserSettingsMetaData.index; - bindingUserSettings = bindingUserSettingsMetaData.userSettings; - - if (bindingType.includes(constants.queue) && functionObject.events[eventsIndex].queue) { - bindingUserSettings[constants.queueName] = functionObject.events[eventsIndex].queue; - } - - if (bindingTypeIndex < 0) { - throw new Error('Binding not supported'); - } - - bindingSettings = parsedBindings.bindingSettings[bindingTypeIndex]; - bindingSettingsNames = parsedBindings.bindingSettingsNames[bindingTypeIndex]; - - if (azureSettings) { - for (let azureSettingKeyIndex = 0; azureSettingKeyIndex < Object.keys(azureSettings).length; azureSettingKeyIndex++) { - const key = Object.keys(azureSettings)[azureSettingKeyIndex]; - - if (bindingSettingsNames.indexOf(key) >= 0) { - bindingUserSettings[key] = azureSettings[key]; +export class Utils { + public static getFunctionMetaData(functionName: string, serverless): FunctionMetadata { + const bindings = []; + let bindingSettingsNames = []; + let bindingSettings = []; + let bindingUserSettings = {}; + let bindingType; + const functionsJson = { disabled: false, bindings: [] }; + const functionObject = serverless.service.getFunction(functionName); + const handler = functionObject.handler; + const events = functionObject.events; + const params: any = { + functionJson: null + }; + + const parsedBindings = BindingUtils.getBindingsMetaData(serverless); + + const bindingTypes = parsedBindings.bindingTypes; + const bindingDisplayNames = parsedBindings.bindingDisplayNames; + + for (let eventsIndex = 0; eventsIndex < events.length; eventsIndex++) { + bindingType = Object.keys(functionObject.events[eventsIndex])[0]; + + if (eventsIndex === 0) { + bindingType += constants.trigger; + } + + const index = bindingTypes.indexOf(bindingType); + + if (index < 0) { + throw new Error(`Binding ${bindingType} not supported`); + } + + serverless.cli.log(`Building binding for function: ${functionName} event: ${bindingType}`); + + bindingUserSettings = {}; + const azureSettings = events[eventsIndex][constants.xAzureSettings]; + let bindingTypeIndex = bindingTypes.indexOf(bindingType); + const bindingUserSettingsMetaData = BindingUtils.getBindingUserSettingsMetaData(azureSettings, bindingType, bindingTypeIndex, bindingDisplayNames); + + bindingTypeIndex = bindingUserSettingsMetaData.index; + bindingUserSettings = bindingUserSettingsMetaData.userSettings; + + if (bindingType.includes(constants.queue) && functionObject.events[eventsIndex].queue) { + bindingUserSettings[constants.queueName] = functionObject.events[eventsIndex].queue; + } + + if (bindingTypeIndex < 0) { + throw new Error('Binding not supported'); + } + + bindingSettings = parsedBindings.bindingSettings[bindingTypeIndex]; + bindingSettingsNames = parsedBindings.bindingSettingsNames[bindingTypeIndex]; + + if (azureSettings) { + for (let azureSettingKeyIndex = 0; azureSettingKeyIndex < Object.keys(azureSettings).length; azureSettingKeyIndex++) { + const key = Object.keys(azureSettings)[azureSettingKeyIndex]; + + if (bindingSettingsNames.indexOf(key) >= 0) { + bindingUserSettings[key] = azureSettings[key]; + } } } + + bindings.push(BindingUtils.getBinding(bindingType, bindingSettings, bindingUserSettings)); } - - bindings.push(this.getBinding(bindingType, bindingSettings, bindingUserSettings, serverless)); - } - - if (bindingType === constants.httpTrigger) { - bindings.push(this.getHttpOutBinding(bindingUserSettings)); - } - - functionsJson.bindings = bindings; - params.functionsJson = functionsJson; - - const entryPointAndHandlerPath = this.getEntryPointAndHandlerPath(handler); - if (functionObject.scriptFile) { - entryPointAndHandlerPath.handlerPath = functionObject.scriptFile; - } - const metaData = { - entryPoint: entryPointAndHandlerPath[constants.entryPoint], - handlerPath: entryPointAndHandlerPath.handlerPath, - params: params - }; - - return metaData; -}; - -export function getBindingUserSettingsMetaData(azureSettings, bindingType, bindingTypeIndex, bindingDisplayNames) { - let bindingDisplayNamesIndex = bindingTypeIndex; - const bindingUserSettings = {}; - - if (azureSettings) { - const directionIndex = Object.keys(azureSettings).indexOf(constants.direction); - - if (directionIndex >= 0) { - const key = Object.keys(azureSettings)[directionIndex]; - const displayName = `$${bindingType}${azureSettings[key]}_displayName`; - - bindingDisplayNamesIndex = bindingDisplayNames.indexOf(displayName.toLowerCase()); - bindingUserSettings[constants.direction] = azureSettings[key]; + + if (bindingType === constants.httpTrigger) { + bindings.push(BindingUtils.getHttpOutBinding(bindingUserSettings)); } - } - const bindingUserSettingsMetaData = { - index: bindingDisplayNamesIndex, - userSettings: bindingUserSettings - }; - - return bindingUserSettingsMetaData; -}; - -export function getEntryPointAndHandlerPath(handler) { - let handlerPath = 'handler.js'; - let entryPoint = handler; - const handlerSplit = handler.split('.'); - - if (handlerSplit.length > 1) { - entryPoint = handlerSplit[handlerSplit.length - 1]; - handlerPath = `${handler.substring(0, handler.lastIndexOf('.'))}.js`; - } - const metaData = { - entryPoint: entryPoint, - handlerPath: handlerPath - }; - - return metaData; -}; - -export function getHttpOutBinding(bindingUserSettings) { - const binding = {}; - - binding[constants.type] = 'http'; - binding[constants.direction] = constants.outDirection; - binding[constants.name] = '$return'; - if (bindingUserSettings[constants.webHookType]) { - binding[constants.name] = 'res'; - } - - return binding; -} - -export function getBinding(bindingType, bindingSettings, bindingUserSettings) { - const binding = {}; - - binding[constants.type] = bindingType; - if (bindingUserSettings && bindingUserSettings[constants.direction]) { - binding[constants.direction] = bindingUserSettings[constants.direction]; - } else if (bindingType.includes(constants.trigger)) { - binding[constants.direction] = constants.inDirection; - } else { - binding[constants.direction] = constants.outDirection; - } - - for (let bindingSettingsIndex = 0; bindingSettingsIndex < bindingSettings.length; bindingSettingsIndex++) { - const name = bindingSettings[bindingSettingsIndex][constants.name]; - - if (bindingUserSettings && bindingUserSettings[name] !== undefined && bindingUserSettings[name] !== null) { - binding[name] = bindingUserSettings[name]; - continue; - } - const value = bindingSettings[bindingSettingsIndex][constants.value]; - const required = bindingSettings[bindingSettingsIndex][constants.required]; - const resource = bindingSettings[bindingSettingsIndex][constants.resource]; - - if (required) { - const defaultValue = bindingSettings[bindingSettingsIndex][constants.defaultValue]; - - if (defaultValue) { - binding[name] = defaultValue; - } else if (name === constants.connection && resource.toLowerCase() === constants.storage) { - binding[name] = 'AzureWebJobsStorage'; - } else { - throw new Error(`Required property ${name} is missing for binding:${bindingType}`); - } + + functionsJson.bindings = bindings; + params.functionsJson = functionsJson; + + const entryPointAndHandlerPath = Utils.getEntryPointAndHandlerPath(handler); + if (functionObject.scriptFile) { + entryPointAndHandlerPath.handlerPath = functionObject.scriptFile; } + const metaData = { + entryPoint: entryPointAndHandlerPath[constants.entryPoint], + handlerPath: entryPointAndHandlerPath.handlerPath, + params: params + }; + + return metaData; + } - if (value === constants.enum && name !== constants.webHookType) { - const enumValues = bindingSettings[bindingSettingsIndex][constants.enum]; - - binding[name] = enumValues[0][constants.value]; + public static getEntryPointAndHandlerPath(handler) { + let handlerPath = 'handler.js'; + let entryPoint = handler; + const handlerSplit = handler.split('.'); + + if (handlerSplit.length > 1) { + entryPoint = handlerSplit[handlerSplit.length - 1]; + handlerPath = `${handler.substring(0, handler.lastIndexOf('.'))}.js`; } + const metaData = { + entryPoint: entryPoint, + handlerPath: handlerPath + }; + + return metaData; } - - return binding; -}; +} diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 9d874803..da96e62f 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -1,3 +1,4 @@ +import { AuthResponse, LinkedSubscription, TokenCredentialsBase } from '@azure/ms-rest-nodeauth'; import Serverless from 'serverless'; import Service from 'serverless/classes/Service'; import Utils = require('serverless/classes/Utils'); @@ -10,12 +11,35 @@ export class MockFactory { sls.utils = MockFactory.createTestUtils(); sls.cli = MockFactory.createTestCli(); sls.pluginManager = MockFactory.createTestPluginManager(); + sls.variables = {}; return sls; } + public static createTestServerlessOptions(): Serverless.Options { + return { + extraServicePath: null, + function: null, + noDeploy: null, + region: null, + stage: null, + watch: null + } + } + + public static createTestAuthResponse(): AuthResponse { + return { + credentials: 'credentials' as any as TokenCredentialsBase, + subscriptions: [ + { + id: 'azureSubId', + } + ] as any as LinkedSubscription[] + } + } + private static createTestService(): Service { return { - getAllFunctions: jest.fn(), + getAllFunctions: jest.fn(() => ['function1']), getFunction: jest.fn(), getAllEventsInFunction: jest.fn(), getAllFunctionsNames: jest.fn(), @@ -27,7 +51,7 @@ export class MockFactory { update: jest.fn(), validate: jest.fn(), custom: null, - provider: null + provider: {} as any, }; } diff --git a/src/test/utils.ts b/src/test/utils.ts new file mode 100644 index 00000000..3210ab3e --- /dev/null +++ b/src/test/utils.ts @@ -0,0 +1,3 @@ +export async function invokeHook(plugin: { hooks: { [eventName: string]: Promise }}, hook: string) { + return await (plugin.hooks[hook] as any)(); +} \ No newline at end of file