diff --git a/package-lock.json b/package-lock.json index f756d844..b52ea548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1794,6 +1794,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "optional": true, "requires": { "kind-of": "^3.0.2", "longest": "^1.0.1", @@ -1966,6 +1967,7 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "optional": true, "requires": { "bn.js": "^4.0.0", "inherits": "^2.0.1", @@ -2435,7 +2437,8 @@ "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "optional": true }, "body-parser": { "version": "1.19.0", @@ -2540,7 +2543,8 @@ "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "optional": true }, "browser-process-hrtime": { "version": "0.1.3", @@ -2569,6 +2573,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "optional": true, "requires": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", @@ -2605,6 +2610,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "optional": true, "requires": { "bn.js": "^4.1.0", "randombytes": "^2.0.1" @@ -2701,7 +2707,8 @@ "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=" + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "optional": true }, "builtin-modules": { "version": "1.1.1", @@ -2888,6 +2895,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "optional": true, "requires": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" @@ -3265,6 +3273,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "optional": true, "requires": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", @@ -3277,6 +3286,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "optional": true, "requires": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", @@ -3740,6 +3750,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "optional": true, "requires": { "bn.js": "^4.4.0", "brorand": "^1.0.1", @@ -3801,6 +3812,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "optional": true, "requires": { "prr": "~1.0.1" } @@ -4180,6 +4192,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "optional": true, "requires": { "d": "1", "es5-ext": "~0.10.14" @@ -4194,6 +4207,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "optional": true, "requires": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" @@ -4723,7 +4737,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4741,11 +4756,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4758,15 +4775,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4869,7 +4889,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4879,6 +4900,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4891,17 +4913,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4918,6 +4943,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4990,7 +5016,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5000,6 +5027,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5075,7 +5103,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5105,6 +5134,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5122,6 +5152,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5160,11 +5191,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -5472,6 +5505,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "optional": true, "requires": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" @@ -5481,6 +5515,7 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "optional": true, "requires": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" @@ -5490,6 +5525,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "optional": true, "requires": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", @@ -5808,7 +5844,8 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", @@ -7448,7 +7485,8 @@ "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "optional": true }, "loose-envify": { "version": "1.4.0", @@ -7549,6 +7587,7 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "optional": true, "requires": { "hash-base": "^3.0.0", "inherits": "^2.0.1", @@ -7574,6 +7613,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "optional": true, "requires": { "errno": "^0.1.3", "readable-stream": "^2.0.1" @@ -7664,12 +7704,14 @@ "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "optional": true }, "minimalistic-crypto-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "optional": true }, "minimatch": { "version": "3.0.4", @@ -8267,6 +8309,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "optional": true, "requires": { "asn1.js": "^4.0.0", "browserify-aes": "^1.0.0", @@ -8375,6 +8418,7 @@ "version": "3.0.17", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "optional": true, "requires": { "create-hash": "^1.1.2", "create-hmac": "^1.1.4", @@ -8577,7 +8621,8 @@ "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "optional": true }, "pseudomap": { "version": "1.0.2", @@ -8644,6 +8689,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "optional": true, "requires": { "safe-buffer": "^5.1.0" } @@ -9056,6 +9102,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "optional": true, "requires": { "hash-base": "^3.0.0", "inherits": "^2.0.1" @@ -9445,6 +9492,7 @@ "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "optional": true, "requires": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" @@ -9640,7 +9688,8 @@ "source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "optional": true }, "source-map": { "version": "0.7.3", @@ -10197,7 +10246,8 @@ "tapable": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz", - "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==" + "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==", + "optional": true }, "tar-stream": { "version": "1.6.2", @@ -11047,6 +11097,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "optional": true, "requires": { "source-list-map": "^2.0.0", "source-map": "~0.6.1" @@ -11055,7 +11106,8 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true } } }, diff --git a/src/models/serverless.ts b/src/models/serverless.ts index 2f009ab8..727d8ffe 100644 --- a/src/models/serverless.ts +++ b/src/models/serverless.ts @@ -58,6 +58,21 @@ export interface ServerlessAzureConfig { functions: any; } +export interface ServerlessCommand { + usage: string; + lifecycleEvents: string[]; + options?: { + [key: string]: { + usage: string; + shortcut?: string; + }; + }; + commands?: ServerlessCommandMap; +} + +export interface ServerlessCommandMap { + [command: string]: ServerlessCommand; +} export interface ServerlessAzureOptions extends Serverless.Options { resourceGroup?: string; } diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index a17f23c3..02ff6490 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -61,6 +61,7 @@ export class AzureDeployPlugin { private async deploy() { const resourceService = new ResourceService(this.serverless, this.options); + await resourceService.deployResourceGroup(); const functionAppService = new FunctionAppService(this.serverless, this.options); diff --git a/src/plugins/invoke/azureInvoke.test.ts b/src/plugins/invoke/azureInvoke.test.ts new file mode 100644 index 00000000..982f562d --- /dev/null +++ b/src/plugins/invoke/azureInvoke.test.ts @@ -0,0 +1,97 @@ +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import mockFs from "mock-fs"; +import { AzureInvoke } from "./azureInvoke"; +jest.mock("../../services/functionAppService"); +jest.mock("../../services/resourceService"); +jest.mock("../../services/invokeService"); +import { InvokeService } from "../../services/invokeService"; + +describe("Azure Invoke Plugin", () => { + const fileContent = JSON.stringify({ + name: "Azure-Test", + }); + afterEach(() => { + jest.resetAllMocks(); + }) + + beforeAll(() => { + mockFs({ + "testFile.json": fileContent, + }, { createCwd: true, createTmp: true }); + }); + afterAll(() => { + mockFs.restore(); + }); + + it("calls invoke hook", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], options["data"]); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + }); + + it("calls the invoke hook with file path", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "testFile.json"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], fileContent); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + + }); + + it("calls the invoke hook with file path", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["path"] = "notExist.json"; + options["method"] = "GET"; + expect(() => new AzureInvoke(sls, options)).toThrow(); + }); + + it("Function invoked with no data", async () => { + const expectedResult = { data: "test" }; + const invoke = jest.fn(() => expectedResult); + InvokeService.prototype.invoke = invoke as any; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = "testApp"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).toBeCalledWith(options["method"], options["function"], undefined); + expect(sls.cli.log).toBeCalledWith(JSON.stringify(expectedResult.data)); + }); + + it("The invoke function fails when no function name is passed", async () => { + const invoke = jest.fn(); + InvokeService.prototype.invoke = invoke; + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + options["function"] = null; + options["data"] = "{\"name\": \"AzureTest\"}"; + options["method"] = "GET"; + const plugin = new AzureInvoke(sls, options); + await invokeHook(plugin, "invoke:invoke"); + expect(invoke).not.toBeCalled(); + }); +}); diff --git a/src/plugins/invoke/azureInvoke.ts b/src/plugins/invoke/azureInvoke.ts index f8da7e26..f5fa5763 100644 --- a/src/plugins/invoke/azureInvoke.ts +++ b/src/plugins/invoke/azureInvoke.ts @@ -1,41 +1,72 @@ +import { isAbsolute, join } from "path"; import Serverless from "serverless"; -import { join, isAbsolute } from "path"; -import AzureProvider from "../../provider/azureProvider"; +import { InvokeService } from "../../services/invokeService"; +import fs from "fs"; +import { ServerlessCommandMap } from "../../models/serverless"; export class AzureInvoke { public hooks: { [eventName: string]: Promise }; - private provider: AzureProvider; - + private commands: ServerlessCommandMap; + private invokeService: InvokeService; public constructor(private serverless: Serverless, private options: Serverless.Options) { - this.provider = (this.serverless.getProvider("azure") as any) as AzureProvider; const path = this.options["path"]; - + if (path) { const absolutePath = isAbsolute(path) ? path : join(this.serverless.config.servicePath, path); + this.serverless.cli.log(this.serverless.config.servicePath); + this.serverless.cli.log(path); - if (!this.serverless.utils.fileExistsSync(absolutePath)) { + if (!fs.existsSync(absolutePath)) { throw new Error("The file you provided does not exist."); } - this.options["data"] = this.serverless.utils.readFileSync(absolutePath); + this.options["data"] = fs.readFileSync(absolutePath).toString(); + } + + this.commands = { + invoke: { + usage: "Invoke command", + lifecycleEvents: ["invoke"], + 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" + } + } + } } this.hooks = { - "before:invoke:invoke": this.provider.getAdminKey.bind(this), "invoke:invoke": this.invoke.bind(this) }; } - + private async invoke() { - const func = this.options.function; - const functionObject = this.serverless.service.getFunction(func); - const eventType = Object.keys(functionObject["events"][0])[0]; - - if (!this.options["data"]) { - this.options["data"] = {}; + const functionName = this.options["function"]; + const data = this.options["data"]; + const method = this.options["method"] || "GET"; + if (!functionName) { + this.serverless.cli.log("Need to provide a name of function to invoke"); + return; } - return this.provider.invoke(func, eventType, this.options["data"]); + this.invokeService = new InvokeService(this.serverless, this.options); + const response = await this.invokeService.invoke(method, functionName, data); + if(response){ + this.serverless.cli.log(JSON.stringify(response.data)); + } } -} +} \ No newline at end of file diff --git a/src/plugins/login/loginPlugin.ts b/src/plugins/login/loginPlugin.ts index 1b36eb68..599c3f58 100644 --- a/src/plugins/login/loginPlugin.ts +++ b/src/plugins/login/loginPlugin.ts @@ -12,6 +12,7 @@ export class AzureLoginPlugin { this.hooks = { "before:package:initialize": this.login.bind(this), "before:deploy:list:list": this.login.bind(this), + "before:invoke:invoke": this.login.bind(this), "before:rollback:rollback": this.login.bind(this), }; } diff --git a/src/services/baseService.ts b/src/services/baseService.ts index aee0061a..f7f1e795 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -121,7 +121,7 @@ export abstract class BaseService { protected getAccessToken(): string { return (this.credentials.tokenCache as any)._entries[0].accessToken; } - + /** * Sends an API request using axios HTTP library * @param method The HTTP method @@ -231,4 +231,4 @@ export abstract class BaseService { } return timestamp; } -} +} \ No newline at end of file diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index 0072afe4..834239ed 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -25,6 +25,9 @@ export class FunctionAppService extends BaseService { public async get(): Promise { const response: any = await this.webClient.webApps.get(this.resourceGroup, FunctionAppResource.getResourceName(this.config)); if (response.error && (response.error.code === "ResourceNotFound" || response.error.code === "ResourceGroupNotFound")) { + this.serverless.cli.log(this.resourceGroup); + this.serverless.cli.log(FunctionAppResource.getResourceName(this.config)); + this.serverless.cli.log(JSON.stringify(response)); return null; } @@ -214,7 +217,7 @@ export class FunctionAppService extends BaseService { return `${deploymentName.replace("rg-deployment", "artifact")}.zip`; } - private getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { + public getFunctionHttpTriggerConfig(functionApp: Site, functionConfig: FunctionEnvelope): FunctionAppHttpTriggerConfig { const httpTrigger = functionConfig.config.bindings.find((binding) => { return binding.type === "httpTrigger"; }); diff --git a/src/services/invokeService.test.ts b/src/services/invokeService.test.ts new file mode 100644 index 00000000..0dd751a5 --- /dev/null +++ b/src/services/invokeService.test.ts @@ -0,0 +1,73 @@ +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; +import { MockFactory } from "../test/mockFactory"; +import { InvokeService } from "./invokeService"; +jest.mock("@azure/arm-appservice") +jest.mock("@azure/arm-resources") +jest.mock("./functionAppService") +import { FunctionAppService } from "./functionAppService"; + +describe("Invoke Service ", () => { + const app = MockFactory.createTestSite(); + const expectedSite = MockFactory.createTestSite(); + const testData = "test-data"; + const testResult = "test-data"; + const authKey = "authKey"; + const baseUrl = "https://management.azure.com" + const masterKeyUrl = `https://${app.defaultHostName}/admin/host/systemkeys/_master`; + const authKeyUrl = `${baseUrl}${app.id}/functions/admin/token?api-version=2016-08-01`; + let urlPOST = `http://${app.defaultHostName}/api/hello`; + let urlGET = `http://${app.defaultHostName}/api/hello?name%3D${testData}`; + let masterKey: string; + + beforeAll(() => { + const axiosMock = new MockAdapter(axios); + // Master Key + axiosMock.onGet(masterKeyUrl).reply(200, { value: masterKey }); + // Auth Key + axiosMock.onGet(authKeyUrl).reply(200, authKey); + //Mock url for GET + axiosMock.onGet(urlGET).reply(200, testResult); + //Mock url for POST + axiosMock.onPost(urlPOST).reply(200, testResult); + }); + + beforeEach(() => { + FunctionAppService.prototype.getMasterKey = jest.fn(); + FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(expectedSite)); + }); + + it("Invokes a function with GET request", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const expectedResult = {url: `${app.defaultHostName}/api/hello`}; + const httpConfig = jest.fn(() => expectedResult); + + FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any; + + options["function"] = "hello"; + options["data"] = `{"name": "${testData}"}`; + options["method"] = "GET"; + + const service = new InvokeService(sls, options); + const response = await service.invoke(options["method"], options["function"], options["data"]); + expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult)); + }); + + it("Invokes a function with POST request", async () => { + const sls = MockFactory.createTestServerless(); + const options = MockFactory.createTestServerlessOptions(); + const expectedResult = {url: `${app.defaultHostName}/api/hello`}; + const httpConfig = jest.fn(() => expectedResult); + FunctionAppService.prototype.getFunctionHttpTriggerConfig = httpConfig as any; + + options["function"] = "hello"; + options["data"] = `{"name": "${testData}"}`; + options["method"] = "POST"; + + const service = new InvokeService(sls, options); + const response = await service.invoke(options["method"], options["function"], options["data"]); + expect(JSON.stringify(response.data)).toEqual(JSON.stringify(testResult)); + }); + +}); \ No newline at end of file diff --git a/src/services/invokeService.ts b/src/services/invokeService.ts new file mode 100644 index 00000000..0b14fbcc --- /dev/null +++ b/src/services/invokeService.ts @@ -0,0 +1,87 @@ +import { BaseService } from "./baseService" +import Serverless from "serverless"; +import axios from "axios"; +import { FunctionAppService } from "./functionAppService"; + +export class InvokeService extends BaseService { + public functionAppService: FunctionAppService; + + public constructor(serverless: Serverless, options: Serverless.Options) { + super(serverless, options); + this.functionAppService = new FunctionAppService(serverless, options); + } + + /** + * Invoke an Azure Function + * @param method HTTP method + * @param functionName Name of function to invoke + * @param data Data to use as body or query params + */ + public async invoke(method: string, functionName: string, data?: any){ + + const functionObject = this.slsFunctions()[functionName]; + /* accesses the admin key */ + if (!functionObject) { + this.serverless.cli.log(`Function ${functionName} does not exist`); + return; + } + + const eventType = Object.keys(functionObject["events"][0])[0]; + + if (eventType !== "http") { + this.log("Needs to be an http function"); + return; + } + + const functionApp = await this.functionAppService.get(); + const functionConfig = await this.functionAppService.getFunction(functionApp, functionName); + const httpConfig = this.functionAppService.getFunctionHttpTriggerConfig(functionApp, functionConfig); + let url = "http://" + httpConfig.url; + + if (method === "GET" && data) { + const queryString = this.getQueryString(data); + url += `?${queryString}` + } + + this.log(url); + const options = await this.getOptions(method, data); + this.log(`Invoking function ${functionName} with ${method} request`); + return await axios(url, options); + } + + private getQueryString(eventData: any) { + if (typeof eventData === "string") { + try { + eventData = JSON.parse(eventData); + } + catch (error) { + return Promise.reject("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("&")); + } + + /** + * Get options object + * @param method The method used (POST or GET) + * @param data Data to use as body or query params + */ + private async getOptions(method: string, data?: any) { + + const functionsAdminKey = await this.functionAppService.getMasterKey(); + const functionApp = await this.functionAppService.get(); + const options: any = { + host: functionApp.defaultHostName, + headers: { + "x-functions-key": functionsAdminKey + }, + method, + data, + }; + + return options; + } +} \ No newline at end of file