diff --git a/docs/examples/apim.md b/docs/examples/apim.md index 7faf378e..ab6c57b4 100644 --- a/docs/examples/apim.md +++ b/docs/examples/apim.md @@ -2,11 +2,44 @@ [API Management](https://azure.microsoft.com/en-us/services/api-management/) is an Azure Service for publishing, managing, securing and monitoring APIs. It can be deployed along with your Serverless function app by specifying its configuration in `serverless.yml`. Here is a basic example of how to configure API Mangement: -## Simple Handler +## Simple Configuration +Simply setting `apim: true` in your configuration will automatically deploy a consumption based APIM resource to Azure. By default it will create a API with path of `/api` in your APIM instance and will map all operations defined in the serverless yaml to your function app. ```yaml service: greeter +provider: + prefix: greeter + name: azure + # Default to West US, allow for command line arg --region to override + region: ${opt:region, 'westus'} + # Default to dev, allow for command line arg -- stage to override + stage: ${opt:stage, 'dev'} + # Azure subscription ID for deployment + subscriptionId: 00000000-0000-0000-0000-000000000000 + + # Start of your API Management configuration + apim: true + +plugins: + - serverless-azure-functions + +functions: + hello: + handler: src/handlers/hello.handler + events: + - http: true + x-azure-settings: + methods: + - GET + authLevel : function +``` + +## Full Configuration +In this example you can see the configuration support is quite verbose. You have the ability to create multiple APIs and Backends as well as associate an operation to a specific api/backend. If the operation is not specifically defined it will default to the first API / Backend that has been defined. +```yaml +service: greeter + provider: prefix: greeter name: azure @@ -20,25 +53,48 @@ provider: # Start of your API Management configuration apim: # API specifications - api: - # Name of the API - name: v1 - subscriptionRequired: false - # Display name - displayName: v1 - # Description of API - description: V1 sample app APIs - # HTTP protocols allowed - protocols: - - https - # Base path of API calls - path: v1 - # Tags for ARM resource - tags: - - tag1 - - tag2 - # No authorization - authorization: none + apis: + # Name of the API + - name: products-api + subscriptionRequired: false + # Display name + displayName: Products API + # Description of API + description: The Products REST API + # HTTP protocols allowed + protocols: + - https + # Base path of API calls + path: products + # Tags for ARM resource + tags: + - tag1 + - tag2 + # No authorization + authorization: none + # Name of the API + - name: categories-api + subscriptionRequired: false + # Display name + displayName: Categories API + # Description of API + description: The Categories REST API + # HTTP protocols allowed + protocols: + - https + # Base path of API calls + path: categories + # Tags for ARM resource + tags: + - tag1 + - tag2 + # No authorization + authorization: none + backends: + - name: products-backend + url: api/products + - name: categories-backend + url: api/categories # CORS Settings for APIM cors: allowCredentials: false @@ -59,26 +115,48 @@ plugins: - serverless-azure-functions functions: - hello: - handler: src/handlers/hello.handler + getProducts: + handler: src/handlers/getProducts.handler # API Management configuration for `hello` handler apim: + # The API to attach this operation + api: products-api + # The Backend use for the operation + backend: products-backend operations: - # GET operation for `hello` handler + # GET operation for `getProducts` handler - method: get # URL path for accessing handler - urlTemplate: /hello + urlTemplate: / # Display name inside Azure Portal - displayName: Hello + displayName: GetProducts events: - http: true x-azure-settings: methods: - GET authLevel : function + getCategories: + handler: src/handlers/getCategories.handler + + # API Management configuration for `getCategories` handler + apim: + # The API to attach this operation + api: categories-api + # The Backend use for the operation + backend: categories-backend + operations: + # GET operation for `getCategories` handler + - method: get + # URL path for accessing handler + urlTemplate: / + # Display name inside Azure Portal + displayName: GetCategories + events: - http: true x-azure-settings: - direction: out - name: res + methods: + - GET + authLevel : function ``` diff --git a/package-lock.json b/package-lock.json index bec65fbe..b0bab973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1832,6 +1832,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", @@ -2004,6 +2005,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", @@ -2850,7 +2852,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", @@ -2955,7 +2958,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", @@ -2984,6 +2988,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", @@ -3020,6 +3025,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" @@ -3116,7 +3122,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", @@ -3303,6 +3310,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" @@ -3666,6 +3674,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", @@ -3678,6 +3687,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", @@ -4140,6 +4150,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", @@ -4201,6 +4212,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" } @@ -4586,6 +4598,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" @@ -4600,6 +4613,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" @@ -5148,11 +5162,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" @@ -5170,11 +5186,13 @@ }, "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", @@ -5277,7 +5295,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -5300,17 +5319,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" @@ -5327,6 +5349,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -5410,6 +5433,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5485,7 +5509,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5515,6 +5540,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", @@ -5571,11 +5597,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -5877,6 +5905,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" @@ -5886,6 +5915,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" @@ -5895,6 +5925,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", @@ -6213,7 +6244,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", @@ -7879,7 +7911,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", @@ -7986,6 +8019,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", @@ -8011,6 +8045,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" @@ -8101,12 +8136,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", @@ -8744,6 +8781,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", @@ -8852,6 +8890,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", @@ -9054,7 +9093,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", @@ -9121,6 +9161,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" } @@ -9533,6 +9574,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" @@ -9963,6 +10005,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" @@ -10158,7 +10201,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", @@ -10715,7 +10759,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", @@ -11571,6 +11616,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" @@ -11579,7 +11625,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/armTemplates/resources/storageAccount.test.ts b/src/armTemplates/resources/storageAccount.test.ts index 7ee5cae4..edc523db 100644 --- a/src/armTemplates/resources/storageAccount.test.ts +++ b/src/armTemplates/resources/storageAccount.test.ts @@ -10,7 +10,9 @@ describe("Storage Account Resource", () => { const config: ServerlessAzureConfig = { functions: [], plugins: [], + package: null, provider: { + runtime: "nodejs10.x", prefix: "sls", name: "azure", region: "westus", diff --git a/src/index.test.ts b/src/index.test.ts index 76e999c8..c54e7abe 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,8 +5,7 @@ import { AzureRemovePlugin } from "./plugins/remove/azureRemovePlugin"; import { AzurePackagePlugin } from "./plugins/package/azurePackagePlugin"; import { AzureDeployPlugin } from "./plugins/deploy/azureDeployPlugin"; import { AzureLoginPlugin } from "./plugins/login/azureLoginPlugin"; -import { AzureApimServicePlugin } from "./plugins/apim/azureApimServicePlugin"; -import { AzureApimFunctionPlugin } from "./plugins/apim/azureApimFunctionPlugin"; +import { AzureApimPlugin } from "./plugins/apim/azureApimPlugin"; import AzureProvider from "./provider/azureProvider"; import { AzureFuncPlugin } from "./plugins/func/azureFuncPlugin"; import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin"; @@ -30,8 +29,7 @@ describe("Azure Index", () => { expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureRemovePlugin); expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureLoginPlugin); expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureDeployPlugin); - expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureApimServicePlugin); - expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureApimFunctionPlugin); + expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureApimPlugin); expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureFuncPlugin); expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureOfflinePlugin); expect(sls.pluginManager.addPlugin).toBeCalledWith(AzureRollbackPlugin); diff --git a/src/index.ts b/src/index.ts index f628de44..59463388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,14 +11,12 @@ import { AzureRemovePlugin } from "./plugins/remove/azureRemovePlugin"; import { AzurePackagePlugin } from "./plugins/package/azurePackagePlugin"; import { AzureDeployPlugin } from "./plugins/deploy/azureDeployPlugin"; import { AzureLoginPlugin } from "./plugins/login/azureLoginPlugin"; -import { AzureApimServicePlugin } from "./plugins/apim/azureApimServicePlugin"; -import { AzureApimFunctionPlugin } from "./plugins/apim/azureApimFunctionPlugin"; +import { AzureApimPlugin } from "./plugins/apim/azureApimPlugin"; import { AzureFuncPlugin } from "./plugins/func/azureFuncPlugin"; import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin" import { AzureRollbackPlugin } from "./plugins/rollback/azureRollbackPlugin" import { AzureKeyVaultPlugin } from "./plugins/identity/azureKeyVaultPlugin" - export default class AzureIndex { public constructor(private serverless: Serverless, private options) { this.serverless.setProvider(AzureProvider.getProviderName(), new AzureProvider(serverless) as any); @@ -30,8 +28,7 @@ export default class AzureIndex { // Refactored this.serverless.pluginManager.addPlugin(AzureLoginPlugin); this.serverless.pluginManager.addPlugin(AzureDeployPlugin); - this.serverless.pluginManager.addPlugin(AzureApimServicePlugin); - this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin); + this.serverless.pluginManager.addPlugin(AzureApimPlugin); this.serverless.pluginManager.addPlugin(AzureFuncPlugin); this.serverless.pluginManager.addPlugin(AzureOfflinePlugin); this.serverless.pluginManager.addPlugin(AzureRollbackPlugin); diff --git a/src/models/apiManagement.ts b/src/models/apiManagement.ts index 3fc656d1..d11270a9 100644 --- a/src/models/apiManagement.ts +++ b/src/models/apiManagement.ts @@ -1,4 +1,4 @@ -import { OperationContract, ApiContract, BackendContract } from "@azure/arm-apimanagement/esm/models"; +import { ApiContract, BackendContract } from "@azure/arm-apimanagement/esm/models"; /** * Defines the serverless APIM configuration @@ -7,9 +7,9 @@ export interface ApiManagementConfig { /** The name of the APIM azure resource */ name: string; /** The API contract configuration */ - api: ApiContract; + apis: ApiContract[]; /** The API's backend contract configuration */ - backend?: BackendContract; + backends?: BackendContract[]; /** The API's CORS policy */ cors?: ApiCorsPolicy; sku?: { @@ -20,16 +20,6 @@ export interface ApiManagementConfig { publisherName?: string; } -/** - * Defines the APIM API Operation configuration - */ -export interface ApiOperationOptions { - /** The name of the serverless function */ - function: string; - /** The APIM operation contract configuration */ - operation: OperationContract; -} - /** * Defines an APIM API CORS (cross origin resource sharing) policy */ @@ -44,4 +34,4 @@ export interface ApiCorsPolicy { allowedHeaders: string[]; /** A list of headers exposed during OPTION preflight requests */ exposeHeaders: string[]; -} \ No newline at end of file +} diff --git a/src/plugins/apim/azureApimFunctionPlugin.test.ts b/src/plugins/apim/azureApimFunctionPlugin.test.ts deleted file mode 100644 index 0e81528a..00000000 --- a/src/plugins/apim/azureApimFunctionPlugin.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { MockFactory } from "../../test/mockFactory"; -import { invokeHook } from "../../test/utils"; -import { AzureApimFunctionPlugin } from "./azureApimFunctionPlugin"; - -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") - }); -}); diff --git a/src/plugins/apim/azureApimFunctionPlugin.ts b/src/plugins/apim/azureApimFunctionPlugin.ts deleted file mode 100644 index c81300c5..00000000 --- a/src/plugins/apim/azureApimFunctionPlugin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Serverless from "serverless"; -import { ApimService } from "../../services/apimService"; -import { AzureBasePlugin } from "../azureBasePlugin"; - -export class AzureApimFunctionPlugin extends AzureBasePlugin { - - public constructor(serverless: Serverless, options: Serverless.Options) { - super(serverless, options); - this.hooks = { - "after:deploy:function:deploy": this.deploy.bind(this) - }; - } - - private async deploy() { - this.serverless.cli.log("Starting APIM function deployment"); - - const apimService = new ApimService(this.serverless, this.options); - const service = await apimService.get(); - const api = await apimService.getApi(); - - await apimService.deployFunction(service, api, this.options); - - this.log("Finished APIM function deployment"); - } -} diff --git a/src/plugins/apim/azureApimServicePlugin.test.ts b/src/plugins/apim/azureApimPlugin.test.ts similarity index 58% rename from src/plugins/apim/azureApimServicePlugin.test.ts rename to src/plugins/apim/azureApimPlugin.test.ts index b7483958..3ff6cec9 100644 --- a/src/plugins/apim/azureApimServicePlugin.test.ts +++ b/src/plugins/apim/azureApimPlugin.test.ts @@ -1,14 +1,14 @@ import Serverless from "serverless"; import { MockFactory } from "../../test/mockFactory"; import { invokeHook } from "../../test/utils"; -import { AzureApimServicePlugin } from "./azureApimServicePlugin"; +import { AzureApimPlugin } from "./azureApimPlugin"; jest.mock("../../services/apimService"); import { ApimService } from "../../services/apimService"; describe("APIM Service Plugin", () => { it("is defined", () => { - expect(AzureApimServicePlugin).toBeDefined(); + expect(AzureApimPlugin).toBeDefined(); }); it("can be instantiated", () => { @@ -17,46 +17,40 @@ describe("APIM Service Plugin", () => { stage: "", region: "", } - const plugin = new AzureApimServicePlugin(serverless, options); + const plugin = new AzureApimPlugin(serverless, options); expect(plugin).not.toBeNull(); }); - it("calls deploy API and deploy functions", async () => { - const deployApi = jest.fn(); - const deployFunctions = jest.fn(); + it("calls APIM service deploy", async () => { + const deploy = jest.fn(); - ApimService.prototype.deployApi = deployApi; - ApimService.prototype.deployFunctions = deployFunctions; + ApimService.prototype.deploy = deploy; const sls = MockFactory.createTestServerless(); sls.service.provider["apim"] = "apim config" const options = MockFactory.createTestServerlessOptions(); - const plugin = new AzureApimServicePlugin(sls, options); + const plugin = new AzureApimPlugin(sls, options); await invokeHook(plugin, "after:deploy:deploy"); expect(sls.cli.log).toBeCalledWith("Starting APIM service deployment") - expect(deployApi).toBeCalled(); - expect(deployFunctions).toBeCalled(); + expect(deploy).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(); + const deploy = jest.fn(); - ApimService.prototype.deployApi = deployApi; - ApimService.prototype.deployFunctions = deployFunctions; + ApimService.prototype.deploy = deploy; const sls = MockFactory.createTestServerless(); const options = MockFactory.createTestServerlessOptions(); - const plugin = new AzureApimServicePlugin(sls, options); + const plugin = new AzureApimPlugin(sls, options); await invokeHook(plugin, "after:deploy:deploy"); expect(sls.cli.log).not.toBeCalled() - expect(deployApi).not.toBeCalled(); - expect(deployFunctions).not.toBeCalled(); + expect(deploy).not.toBeCalled(); }); }); diff --git a/src/plugins/apim/azureApimServicePlugin.ts b/src/plugins/apim/azureApimPlugin.ts similarity index 71% rename from src/plugins/apim/azureApimServicePlugin.ts rename to src/plugins/apim/azureApimPlugin.ts index d900fc5e..500f4414 100644 --- a/src/plugins/apim/azureApimServicePlugin.ts +++ b/src/plugins/apim/azureApimPlugin.ts @@ -2,12 +2,12 @@ import Serverless from "serverless"; import { ApimService } from "../../services/apimService"; import { AzureBasePlugin } from "../azureBasePlugin"; -export class AzureApimServicePlugin extends AzureBasePlugin { +export class AzureApimPlugin extends AzureBasePlugin { public constructor(serverless: Serverless, options: Serverless.Options) { super(serverless, options); this.hooks = { - "after:deploy:deploy": this.deploy.bind(this) + "after:deploy:deploy": this.deploy.bind(this), }; } @@ -20,9 +20,7 @@ export class AzureApimServicePlugin extends AzureBasePlugin { this.serverless.cli.log("Starting APIM service deployment"); const apimService = new ApimService(this.serverless, this.options); - const service = await apimService.get(); - const api = await apimService.deployApi(); - await apimService.deployFunctions(service, api); + await apimService.deploy(); this.log("Finished APIM service deployment"); } diff --git a/src/plugins/deploy/azureDeployPlugin.test.ts b/src/plugins/deploy/azureDeployPlugin.test.ts index 208670c0..dbf2bc8c 100644 --- a/src/plugins/deploy/azureDeployPlugin.test.ts +++ b/src/plugins/deploy/azureDeployPlugin.test.ts @@ -1,6 +1,6 @@ import { Site } from "@azure/arm-appservice/esm/models"; import Serverless from "serverless"; -import { ServerlessAzureOptions } from "../../models/serverless"; +import { ServerlessAzureOptions, ServerlessAzureConfig } from "../../models/serverless"; import { MockFactory } from "../../test/mockFactory"; import { invokeHook } from "../../test/utils"; import { AzureDeployPlugin } from "./azureDeployPlugin"; @@ -11,6 +11,7 @@ import { FunctionAppService } from "../../services/functionAppService"; jest.mock("../../services/resourceService"); import { ResourceService } from "../../services/resourceService"; +import { ApimService } from "../../services/apimService"; describe("Deploy plugin", () => { let sls: Serverless; @@ -25,6 +26,7 @@ describe("Deploy plugin", () => { beforeEach(() => { FunctionAppService.prototype.getFunctionZipFile = jest.fn(() => "serviceName.zip"); + ApimService.prototype.deploy = jest.fn(); sls = MockFactory.createTestServerless(); options = MockFactory.createTestServerlessOptions(); @@ -64,7 +66,7 @@ describe("Deploy plugin", () => { "Azure Functions are zipped up as a package and deployed together as a unit"); }); - it("lists deployments", async () => { + it("lists deployments from sub-command", async () => { const deploymentString = "deployments"; ResourceService.prototype.listDeployments = jest.fn(() => Promise.resolve(deploymentString)); await invokeHook(plugin, "deploy:list:list"); @@ -72,7 +74,21 @@ describe("Deploy plugin", () => { expect(sls.cli.log).lastCalledWith(deploymentString); }); - it("Crashes deploy list if function is specified", async () => { + it("deploys APIM from sub-command if configured", async () => { + (sls.service as any as ServerlessAzureConfig).provider.apim = {} as any; + plugin = new AzureDeployPlugin(sls, {} as any); + await invokeHook(plugin, "deploy:apim:apim"); + expect(ApimService.prototype.deploy).toBeCalled(); + }); + + it("skips deployment of APIM from sub-command if not configured", async () => { + delete (sls.service as any as ServerlessAzureConfig).provider.apim; + plugin = new AzureDeployPlugin(sls, {} as any); + await invokeHook(plugin, "deploy:apim:apim"); + expect(ApimService.prototype.deploy).not.toBeCalled(); + }); + + it("crashes deploy list if function is specified", async () => { plugin = new AzureDeployPlugin(sls, { function: "myFunction" } as any); await expect(invokeHook(plugin, "deploy:list:list")) .rejects.toThrow("The Azure Functions plugin does not currently support deployments of individual functions. " + diff --git a/src/plugins/deploy/azureDeployPlugin.ts b/src/plugins/deploy/azureDeployPlugin.ts index 4b1b8ea7..8d7cf3ec 100644 --- a/src/plugins/deploy/azureDeployPlugin.ts +++ b/src/plugins/deploy/azureDeployPlugin.ts @@ -4,6 +4,7 @@ import { FunctionAppService } from "../../services/functionAppService"; import { AzureLoginOptions } from "../../services/loginService"; import { ResourceService } from "../../services/resourceService"; import { AzureBasePlugin } from "../azureBasePlugin"; +import { ApimService } from "../../services/apimService"; export class AzureDeployPlugin extends AzureBasePlugin { public commands: any; @@ -14,65 +15,49 @@ export class AzureDeployPlugin extends AzureBasePlugin { this.hooks = { "deploy:deploy": this.deploy.bind(this), "deploy:list:list": this.list.bind(this), + "deploy:apim:apim": this.deployApim.bind(this), }; + const deployOptions = { + resourceGroup: { + usage: "Resource group for the service", + shortcut: "g", + }, + stage: { + usage: "Stage of service", + shortcut: "s" + }, + region: { + usage: "Region of service", + shortcut: "r" + }, + subscriptionId: { + usage: "Sets the Azure subscription ID", + shortcut: "i", + }, + function: { + usage: "Deployment of individual function - NOT SUPPORTED", + shortcut: "f", + } + } + this.commands = { deploy: { commands: { list: { usage: "List deployments", - lifecycleEvents: [ - "list" - ] - }, - options: { - resourceGroup: { - usage: "Resource group for the service", - shortcut: "g", - }, - stage: { - usage: "Stage of service", - shortcut: "s" - }, - region: { - usage: "Region of service", - shortcut: "r" - }, - subscriptionId: { - usage: "Sets the Azure subscription ID", - shortcut: "i", - }, - function: { - usage: "Deployment of individual function - NOT SUPPORTED", - shortcut: "f", + lifecycleEvents: ["list"], + options: { + ...deployOptions } + }, + apim: { + usage: "Deploys APIM", + lifecycleEvents: ["apim"] } }, options: { - resourceGroup: { - usage: "Resource group for the service", - shortcut: "g", - }, - stage: { - usage: "Stage of service", - shortcut: "s" - }, - region: { - usage: "Region of service", - shortcut: "r" - }, - subscriptionId: { - usage: "Sets the Azure subscription ID", - shortcut: "i", - }, - package: { - usage: "Package to deploy", - shortcut: "p", - }, - function: { - usage: "Deployment of individual function - NOT SUPPORTED", - shortcut: "f", - } + ...deployOptions } } } @@ -98,6 +83,24 @@ export class AzureDeployPlugin extends AzureBasePlugin { await functionAppService.uploadFunctions(functionApp); } + /** + * Deploys APIM if configured + */ + private async deployApim() { + const apimConfig = this.serverless.service.provider["apim"]; + if (!apimConfig) { + this.log("No APIM configuration found"); + return Promise.resolve(); + } + + this.serverless.cli.log("Starting APIM service deployment"); + + const apimService = new ApimService(this.serverless, this.options); + await apimService.deploy(); + + this.log("Finished APIM service deployment"); + } + /** * Check to see if user tried to target an individual function for deployment or deployment list * Throws error if `function` is specified diff --git a/src/plugins/identity/azureKeyVaultPlugin.ts b/src/plugins/identity/azureKeyVaultPlugin.ts index 999d3d32..8be800e8 100644 --- a/src/plugins/identity/azureKeyVaultPlugin.ts +++ b/src/plugins/identity/azureKeyVaultPlugin.ts @@ -15,13 +15,11 @@ export class AzureKeyVaultPlugin extends AzureBasePlugin { if (!keyVaultConfig) { return Promise.resolve(); } - this.serverless.cli.log("Starting KeyVault service setup"); + this.log("Starting KeyVault service setup"); const keyVaultService = new AzureKeyVaultService(this.serverless, this.options); - const result = await keyVaultService.setPolicy(keyVaultConfig); + await keyVaultService.setPolicy(keyVaultConfig); - this.serverless.cli.log("Finished KeyVault service setup"); - - return result; + this.log("Finished KeyVault service setup"); } } diff --git a/src/plugins/login/loginHooks.ts b/src/plugins/login/loginHooks.ts index 97db4a30..44f44756 100644 --- a/src/plugins/login/loginHooks.ts +++ b/src/plugins/login/loginHooks.ts @@ -4,6 +4,7 @@ export const loginHooks = [ "deploy:list:list", "deploy:deploy", + "deploy:apim:apim", "invoke:invoke", "rollback:rollback", "remove:remove", diff --git a/src/services/apimService.test.ts b/src/services/apimService.test.ts index 4711370e..52665b81 100644 --- a/src/services/apimService.test.ts +++ b/src/services/apimService.test.ts @@ -10,7 +10,6 @@ import apimGetService200 from "../test/responses/apim-get-service-200.json"; import apimGetApi200 from "../test/responses/apim-get-api-200.json"; import apimGetApi404 from "../test/responses/apim-get-api-404.json"; import { FunctionAppService } from "./functionAppService"; -import { Site } from "@azure/arm-appservice/esm/models"; import { PropertyContract, BackendContract, BackendCreateOrUpdateResponse, ApiCreateOrUpdateResponse, PropertyCreateOrUpdateResponse, ApiContract, @@ -20,12 +19,22 @@ import { ApiPolicyCreateOrUpdateResponse, } from "@azure/arm-apimanagement/esm/models"; import { AzureNamingService } from "./namingService"; +import { ApiManagementConfig } from "../models/apiManagement"; describe("APIM Service", () => { - const apimConfig = MockFactory.createTestApimConfig(); + let apimConfig: ApiManagementConfig; + + const functionApp = { + id: "/testapp1", + name: "Test Site", + location: "West US", + defaultHostName: "testsite.azurewebsites.net", + }; + let serverless: Serverless; beforeEach(() => { + apimConfig = MockFactory.createTestApimConfig(); const slsConfig: any = { ...MockFactory.createTestService(MockFactory.createTestSlsFunctionConfig()), service: "test-sls", @@ -114,7 +123,7 @@ describe("APIM Service", () => { axios.request = jest.fn((requestConfig) => MockFactory.createTestAxiosResponse(requestConfig, apimGetApi404, 404)); const service = new ApimService(serverless); - const api = await service.getApi(); + const api = await service.getApi("unknown"); expect(api).toBeNull(); }); @@ -122,12 +131,14 @@ describe("APIM Service", () => { serverless.service.provider["apim"] = null; const service = new ApimService(serverless); - const api = await service.getApi(); + const api = await service.getApi("unknown"); expect(api).toBeNull(); }); it("returns the API reference", async () => { + const defaultApi = apimConfig.apis[0]; + const expectedResponse = interpolateJson(apimGetApi200, { resourceGroup: { name: serverless.service.provider["resourceGroup"], @@ -137,23 +148,23 @@ describe("APIM Service", () => { name: apimConfig.name, }, resource: { - name: apimConfig.api.name, - displayName: apimConfig.api.displayName, - description: apimConfig.api.description, - path: apimConfig.api.path, + name: defaultApi.name, + displayName: defaultApi.displayName, + description: defaultApi.description, + path: defaultApi.path, }, }); axios.request = jest.fn((requestConfig) => MockFactory.createTestAxiosResponse(requestConfig, expectedResponse)); const service = new ApimService(serverless); - const api = await service.getApi(); + const api = await service.getApi(defaultApi.name); expect(api).not.toBeNull(); expect(api).toMatchObject({ - displayName: apimConfig.api.displayName, - description: apimConfig.api.description, - path: apimConfig.api.path, + displayName: defaultApi.displayName, + description: defaultApi.description, + path: defaultApi.path, }); }); }); @@ -165,7 +176,6 @@ describe("APIM Service", () => { let serviceName: string; let apiName: string; let backendName: string; - let functionApp: Site; let masterKey: string; let expectedApi: ApiContract; let expectedApiResult: ApiContract; @@ -173,19 +183,13 @@ describe("APIM Service", () => { let expectedProperty: PropertyContract; beforeEach(() => { - backendConfig = apimConfig.backend || {} as BackendContract; + backendConfig = apimConfig.backends[0] || {} as BackendContract; resourceGroupName = serverless.service.provider["resourceGroup"]; appName = serverless.service["service"]; serviceName = apimConfig.name; - apiName = apimConfig.api.name; - backendName = backendConfig.name || appName; + apiName = apimConfig.apis[0].name; + backendName = backendConfig[0] ? backendConfig[0].name : `${appName}-backend`; - functionApp = { - id: "/testapp1", - name: "Test Site", - location: "West US", - defaultHostName: "testsite.azurewebsites.net", - }; masterKey = "ABC123"; FunctionAppService.prototype.get = jest.fn(() => Promise.resolve(functionApp)); @@ -193,22 +197,23 @@ describe("APIM Service", () => { expectedApi = { isCurrent: true, - subscriptionRequired: apimConfig.api.subscriptionRequired, - displayName: apimConfig.api.displayName, - description: apimConfig.api.description, - path: apimConfig.api.path, - protocols: apimConfig.api.protocols, + name: apimConfig.apis[0].name, + subscriptionRequired: apimConfig.apis[0].subscriptionRequired, + displayName: apimConfig.apis[0].displayName, + description: apimConfig.apis[0].description, + path: apimConfig.apis[0].path, + protocols: apimConfig.apis[0].protocols, }; expectedApiResult = { - id: apimConfig.api.name, - name: apimConfig.api.name, + id: apimConfig.apis[0].name, + name: apimConfig.apis[0].name, isCurrent: true, - subscriptionRequired: apimConfig.api.subscriptionRequired, - displayName: apimConfig.api.displayName, - description: apimConfig.api.description, - path: apimConfig.api.path, - protocols: apimConfig.api.protocols, + subscriptionRequired: apimConfig.apis[0].subscriptionRequired, + displayName: apimConfig.apis[0].displayName, + description: apimConfig.apis[0].description, + path: apimConfig.apis[0].path, + protocols: apimConfig.apis[0].protocols, }; expectedBackend = { @@ -217,10 +222,11 @@ describe("APIM Service", () => { "x-functions-key": [`{{${serverless.service["service"]}-key}}`], }, }, + name: backendConfig[0] ? backendConfig[0].name : `${appName}-backend`, title: backendConfig.title || functionApp.name, tls: backendConfig.tls, proxy: backendConfig.proxy, - description: backendConfig.description, + description: backendConfig.description || "Function App Backend", protocol: backendConfig.protocol || "http", resourceId: `https://management.azure.com${functionApp.id}`, url: `https://${functionApp.defaultHostName}/api`, @@ -243,9 +249,8 @@ describe("APIM Service", () => { ApiPolicy.prototype.createOrUpdate = jest.fn(() => Promise.resolve(null)); const apimService = new ApimService(serverless); - const result = await apimService.deployApi(); - expect(result).toMatchObject(expectedApiResult); + await expect(apimService.deploy()).resolves.not.toBeNull(); expect(Api.prototype.createOrUpdate).toBeCalledWith( resourceGroupName, serviceName, @@ -285,7 +290,7 @@ describe("APIM Service", () => { serverless.service.provider["apim"]["cors"] = corsPolicy; const apimService = new ApimService(serverless); - const result = await apimService.deployApi(); + const result = await apimService.deploy(); expect(result).not.toBeNull(); expect(ApiPolicy.prototype.createOrUpdate).toBeCalledWith( @@ -303,7 +308,7 @@ describe("APIM Service", () => { serverless.service.provider["apim"] = null; const service = new ApimService(serverless); - const api = await service.deployApi(); + const api = await service.deploy(); expect(api).toBeNull(); }); @@ -313,7 +318,7 @@ describe("APIM Service", () => { Api.prototype.createOrUpdate = jest.fn(() => Promise.reject(apiError)); const apimService = new ApimService(serverless); - await expect(apimService.deployApi()).rejects.toEqual(apiError); + await expect(apimService.deploy()).rejects.toEqual(apiError); }); it("fails when Backend deployment fails", async () => { @@ -324,7 +329,7 @@ describe("APIM Service", () => { Backend.prototype.createOrUpdate = jest.fn(() => Promise.reject(apiError)); const apimService = new ApimService(serverless); - await expect(apimService.deployApi()).rejects.toEqual(apiError); + await expect(apimService.deploy()).rejects.toEqual(apiError); }); it("fails when Property deployment fails", async () => { @@ -337,7 +342,113 @@ describe("APIM Service", () => { Property.prototype.createOrUpdate = jest.fn(() => Promise.reject(apiError)); const apimService = new ApimService(serverless); - await expect(apimService.deployApi()).rejects.toEqual(apiError); + await expect(apimService.deploy()).rejects.toEqual(apiError); + }); + + it("automatically creates API and Backend if not explicitly defined", async () => { + apimConfig.apis = []; + apimConfig.backends = []; + + Api.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedApiResult, 201)); + Backend.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedBackend, 201)); + Property.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + ApiPolicy.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + + const apimService = new ApimService(serverless); + await apimService.deploy(); + + expect(Api.prototype.createOrUpdate).toBeCalled(); + expect(Backend.prototype.createOrUpdate).toBeCalled(); + }); + + it("creates multiple API's & Backends", async () => { + apimConfig.apis = MockFactory.createTestApimApis(3); + apimConfig.backends = MockFactory.createTestApimBackends(3); + + Api.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedApiResult, 201)); + Backend.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedBackend, 201)); + Property.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + ApiPolicy.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + + const apimService = new ApimService(serverless); + await apimService.deploy(); + + expect(Api.prototype.createOrUpdate).toBeCalledTimes(apimConfig.apis.length); + expect(Backend.prototype.createOrUpdate).toBeCalledTimes(apimConfig.apis.length); + }); + + it("infers APIM operation configuration from HTTP binding", async () => { + const functions = MockFactory.createTestSlsFunctionConfig(); + Object.assign(serverless.service, { functions }); + + let apimResource: ApiManagementServiceResource = { + name: apimConfig.name, + location: "West US", + gatewayUrl: "https://you.url.com", + publisherEmail: "someone@example.com", + publisherName: "Someone", + sku: { + capacity: 1, + name: "Consumption", + }, + }; + + ApiManagementService.prototype.get = + jest.fn(() => MockFactory.createTestArmSdkResponse(apimResource, 200)); + Api.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedApiResult, 201)); + Backend.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedBackend, 201)); + Property.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + ApiPolicy.prototype.createOrUpdate = + jest.fn(() => MockFactory.createTestArmSdkResponse(expectedProperty, 201)); + ApiOperation.prototype.createOrUpdate = + jest.fn((resourceGroup, serviceName, apiName, operationName, operationContract) => { + const response = MockFactory.createTestArmSdkResponse(operationContract, 201); + return Promise.resolve(response); + }); + + const apimService = new ApimService(serverless); + await apimService.deploy(); + + expect(ApiOperation.prototype.createOrUpdate).toBeCalledTimes(2) + expect(ApiOperation.prototype.createOrUpdate).toBeCalledWith( + resourceGroupName, + serviceName, + apiName, + "hello", + { + displayName: "hello", + description: "", + method: "get", + urlTemplate: "hello", + templateParameters: [], + responses: [], + } + ); + expect(ApiOperation.prototype.createOrUpdate).toBeCalledWith( + resourceGroupName, + serviceName, + apiName, + "goodbye", + { + displayName: "goodbye", + description: "", + method: "get", + urlTemplate: "goodbye", + templateParameters: [], + responses: [], + } + ); }); }); @@ -349,9 +460,7 @@ describe("APIM Service", () => { const deploySpy = jest.spyOn(apimService, "deployFunction"); const serviceResource: ApiManagementServiceResource = MockFactory.createTestApimService(); - const api: ApiContract = MockFactory.createTestApimApi(); - - await apimService.deployFunctions(serviceResource, api); + await apimService.deployFunctions(functionApp, serviceResource); expect(deploySpy).not.toBeCalled(); }); @@ -359,7 +468,7 @@ describe("APIM Service", () => { it("ensures all serverless functions have been deployed into specified API", async () => { const slsFunctions = _.values(serverless.service["functions"]); - const apimResource: ApiManagementServiceResource = { + let apimResource: ApiManagementServiceResource = { name: apimConfig.name, location: "West US", publisherEmail: "someone@example.com", @@ -370,9 +479,9 @@ describe("APIM Service", () => { }, }; - const apiContract = apimConfig.api; + const apiContracts = apimConfig.apis; ApiManagementService.prototype.get = jest.fn(() => MockFactory.createTestArmSdkResponse(apimResource, 200)); - Api.prototype.get = jest.fn(() => MockFactory.createTestArmSdkResponse(apiContract, 200)); + Api.prototype.get = jest.fn(() => MockFactory.createTestArmSdkResponse(apiContracts[0], 200)); ApiOperation.prototype.createOrUpdate = jest.fn((resourceGroup, serviceName, apiName, operationName, operationContract) => { @@ -385,11 +494,12 @@ describe("APIM Service", () => { const deployFunctionSpy = jest.spyOn(ApimService.prototype, "deployFunction"); const service = new ApimService(serverless); - const apimInstance = await service.get(); - const api = await service.getApi(); + apimResource = await service.get(); + const api = await service.getApi(apimConfig.apis[0].name); - await service.deployFunctions(apimInstance, api); + await service.deploy(); + expect(api).not.toBeNull(); expect(deployFunctionSpy).toBeCalledTimes(slsFunctions.length); const createOperationCall = ApiOperation.prototype.createOrUpdate as jest.Mock; diff --git a/src/services/apimService.ts b/src/services/apimService.ts index f66a722b..22ca4743 100644 --- a/src/services/apimService.ts +++ b/src/services/apimService.ts @@ -3,10 +3,10 @@ import xml from "xml"; import { ApiManagementClient } from "@azure/arm-apimanagement"; import { FunctionAppService } from "./functionAppService"; import { BaseService } from "./baseService"; -import { ApiManagementConfig, ApiOperationOptions, ApiCorsPolicy } from "../models/apiManagement"; +import { ApiManagementConfig, ApiCorsPolicy } from "../models/apiManagement"; import { - ApiContract, BackendContract, OperationContract, - PropertyContract, ApiManagementServiceResource, + ApiContract, OperationContract, + PropertyContract, ApiManagementServiceResource, BackendContract, } from "@azure/arm-apimanagement/esm/models"; import { Site } from "@azure/arm-appservice/esm/models"; import { Guard } from "../shared/guard"; @@ -28,12 +28,23 @@ export class ApimService extends BaseService { return; } + if (typeof (this.apimConfig) === "boolean") { + this.apimConfig = { + name: null, + apis: [], + }; + } + if (!this.apimConfig.name) { this.apimConfig.name = ApimResource.getResourceName(this.config); } - if (!this.apimConfig.backend) { - this.apimConfig.backend = {} as any; + if (!this.apimConfig.apis) { + this.apimConfig.apis = []; + } + + if (!this.apimConfig.backends) { + this.apimConfig.backends = []; } this.apimClient = new ApiManagementClient(this.credentials, this.subscriptionId); @@ -55,13 +66,19 @@ export class ApimService extends BaseService { } } - public async getApi(): Promise { - if (!(this.apimConfig && this.apimConfig.api && this.apimConfig.api.name)) { + /** + * Gets the APIM API by API name + * @param apiName The API to retrieve + */ + public async getApi(apiName: string): Promise { + Guard.empty(apiName); + + if (!(this.apimConfig && this.apimConfig.name)) { return null; } try { - return await this.apimClient.api.get(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name); + return await this.apimClient.api.get(this.resourceGroup, this.apimConfig.name, apiName); } catch (err) { return null; } @@ -70,26 +87,30 @@ export class ApimService extends BaseService { /** * Deploys the APIM top level api */ - public async deployApi() { + public async deploy() { if (!(this.apimConfig && this.apimConfig.name)) { return null; } const functionApp = await this.functionAppService.get(); - - const api = await this.ensureApi(); + this.setApimDefaults(functionApp); await this.ensureFunctionAppKeys(functionApp); - await this.ensureBackend(functionApp); - return api; + const resource = await this.get(); + const apiTasks = this.apimConfig.apis.map((api) => this.ensureApi(api)); + const backendTasks = this.apimConfig.backends.map((backend) => this.ensureBackend(functionApp, backend)); + + await Promise.all(apiTasks); + await Promise.all(backendTasks); + + await this.deployFunctions(functionApp, resource); } /** * Deploys all the functions of the serverless service to APIM */ - public async deployFunctions(service: ApiManagementServiceResource, api: ApiContract) { - Guard.null(service); - Guard.null(api); + public async deployFunctions(functionApp: Site, resource: ApiManagementServiceResource) { + Guard.null(resource); if (!(this.apimConfig && this.apimConfig.name)) { return null; @@ -99,56 +120,103 @@ export class ApimService extends BaseService { const deployApiTasks = this.serverless.service .getAllFunctions() - .map((functionName) => this.deployFunction(service, api, { function: functionName })); + .map((functionName) => this.deployFunction(functionApp, resource, functionName)); - return Promise.all(deployApiTasks); + return await Promise.all(deployApiTasks); } /** * Deploys the specified serverless function to APIM * @param options */ - public async deployFunction(service: ApiManagementServiceResource, api: ApiContract, options) { - Guard.null(service); - Guard.null(api); - Guard.null(options); + public async deployFunction(functionApp: Site, resource: ApiManagementServiceResource, functionName: string) { + Guard.null(functionApp); + Guard.null(resource); + Guard.empty(functionName); - const functionConfig = this.serverless.service["functions"][options.function]; + const functionConfig = this.serverless.service["functions"][functionName]; + functionConfig.name = functionName; - if (!(functionConfig && functionConfig.apim)) { + const httpEvent = functionConfig.events.find((event) => event.http); + if (!httpEvent) { return; } - const tasks = functionConfig.apim.operations.map((operation) => { - return this.deployOperation(service, api, { - function: options.function, - operation, - }); - }); + const httpConfig = httpEvent["x-azure-settings"]; + + // Infer APIM operation configuration from HTTP event if not already set + if (!functionConfig.apim) { + const operations = httpConfig.methods.map((method) => { + return { + name: functionConfig.name, + displayName: functionConfig.name, + urlTemplate: httpConfig.route || functionConfig.name, + method: method, + templateParameters: this.getTemplateParameters(httpConfig.route) + }; + }) + + functionConfig.apim = { operations }; + } + + // Lookup api mapping + const apiContract = functionConfig.apim.api + ? this.apimConfig.apis.find((api) => api.name === functionConfig.apim.api) + : this.apimConfig.apis[0]; + + // Lookup backend mapping + const backendContract = functionConfig.apim.backend + ? this.apimConfig.backends.find((backend) => backend.name === functionConfig.apim.backend) + : this.apimConfig.backends[0]; + + const tasks = functionConfig.apim.operations + .map((operation) => this.deployOperation(resource, apiContract, backendContract, operation, functionName)); await Promise.all(tasks); } + /** + * Retrieves the template parameter referenced in the route template + * @param route The route template to inspect + */ + private getTemplateParameters(route: string) { + const regex = new RegExp(/{(\w+)}/g); + const matches = []; + while (true) { + const match = regex.exec(route); + if (!match) { + break; + } + + matches.push(match); + }; + + if (matches.length === 0) { + return null; + } + + return matches.map((match) => ({ + name: match[1], + type: "string", + })); + } + /** * Deploys the APIM API referenced by the serverless service */ - private async ensureApi(): Promise { - this.log("-> Deploying API"); + private async ensureApi(apiContract: ApiContract): Promise { + this.log(`-> Deploying API: ${apiContract.name}`); try { - const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, { + const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.apimConfig.name, apiContract.name, { + ...apiContract, isCurrent: true, - subscriptionRequired: this.apimConfig.api.subscriptionRequired, - displayName: this.apimConfig.api.displayName, - description: this.apimConfig.api.description, - path: this.apimConfig.api.path, - protocols: this.apimConfig.api.protocols, }); if (this.apimConfig.cors) { this.log("-> Deploying CORS policy"); - await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, { + await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, apiContract.name, { format: "rawxml", value: this.createCorsXmlPolicy(this.apimConfig.cors) }); @@ -166,24 +234,25 @@ export class ApimService extends BaseService { * Deploys the APIM Backend referenced by the serverless service * @param functionAppUrl The host name for the deployed function app */ - private async ensureBackend(functionApp: Site): Promise { - const backendUrl = `https://${functionApp.defaultHostName}/api`; + private async ensureBackend(functionApp: Site, backendContract: BackendContract): Promise { + const backendUrl = `https://${functionApp.defaultHostName}/${backendContract.url}`; - this.log(`-> Deploying API Backend ${functionApp.name} = ${backendUrl}`); + this.log(`-> Deploying API Backend: ${backendContract.name} => ${backendUrl}`); try { const functionAppResourceId = `https://management.azure.com${functionApp.id}`; - return await this.apimClient.backend.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.serviceName, { + return await this.apimClient.backend.createOrUpdate(this.resourceGroup, this.apimConfig.name, backendContract.name, { credentials: { header: { "x-functions-key": [`{{${this.serviceName}-key}}`], }, }, - title: this.apimConfig.backend.title || functionApp.name, - tls: this.apimConfig.backend.tls, - proxy: this.apimConfig.backend.proxy, - description: this.apimConfig.backend.description, - protocol: this.apimConfig.backend.protocol || "http", + name: backendContract.name, + title: backendContract.title || functionApp.name, + tls: backendContract.tls, + proxy: backendContract.proxy, + description: backendContract.description, + protocol: backendContract.protocol || "http", resourceId: functionAppResourceId, url: backendUrl, }); @@ -199,44 +268,40 @@ export class ApimService extends BaseService { * @param serverless The serverless framework * @param options The plugin options */ - private async deployOperation( - service: ApiManagementServiceResource, - api: ApiContract, - options: ApiOperationOptions, - ): Promise { + private async deployOperation(resource: ApiManagementServiceResource, api: ApiContract, backend: BackendContract, operation: OperationContract, functionName: string): Promise { try { const client = new ApiManagementClient(this.credentials, this.subscriptionId); const operationConfig: OperationContract = { - displayName: options.operation.displayName || options.function, - description: options.operation.description || "", - method: options.operation.method, - urlTemplate: options.operation.urlTemplate, - templateParameters: options.operation.templateParameters || [], - responses: options.operation.responses || [], + displayName: operation.displayName || functionName, + description: operation.description || "", + method: operation.method, + urlTemplate: operation.urlTemplate, + templateParameters: operation.templateParameters || [], + responses: operation.responses || [], }; // Ensure a single path seperator in the operation path const operationPath = `/${api.path}/${operationConfig.urlTemplate}`.replace(/\/+/g, "/"); - const operationUrl = `${service.gatewayUrl}${operationPath}`; - this.log(`--> Deploying API operation ${options.function}: ${operationConfig.method.toUpperCase()} ${operationUrl}`); + const operationUrl = `${resource.gatewayUrl}${operationPath}`; + this.log(`--> ${functionName}: [${operationConfig.method.toUpperCase()}] ${operationUrl}`); - const operation = await client.apiOperation.createOrUpdate( + const result = await client.apiOperation.createOrUpdate( this.resourceGroup, this.apimConfig.name, - this.apimConfig.api.name, - options.function, + api.name, + functionName, operationConfig, ); - await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, this.apimConfig.api.name, options.function, { + await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.apimConfig.name, api.name, functionName, { format: "rawxml", - value: this.createApiOperationXmlPolicy(), + value: this.createApiOperationXmlPolicy(backend.name), }); - return operation; + return result; } catch (e) { - this.log(`Error deploying API operation ${options.function}`); + this.log(`Error deploying API operation ${functionName}`); this.log(JSON.stringify(e.body, null, 4)); throw e; } @@ -267,7 +332,7 @@ export class ApimService extends BaseService { /** * Creates the XML payload that defines the API operation policy to link to the configured backend */ - private createApiOperationXmlPolicy(): string { + private createApiOperationXmlPolicy(backendId: string): string { const operationPolicy = [{ policies: [ { @@ -278,7 +343,7 @@ export class ApimService extends BaseService { { "_attr": { "id": "apim-generated-policy", - "backend-id": this.serviceName, + "backend-id": backendId, } }, ], @@ -328,4 +393,45 @@ export class ApimService extends BaseService { return xml(policy, { indent: "\t" }); } + + /** + * Sets up APIM defaults if not explicitly defined + * @param functionApp The function app resource + */ + private setApimDefaults(functionApp: Site) { + const defaultApi: ApiContract = { + isCurrent: true, + name: `${this.serviceName}-api`, + subscriptionRequired: false, + displayName: "API", + description: "", + path: "api", + protocols: ["http", "https"] + } + + if (this.apimConfig.apis.length === 0) { + this.apimConfig.apis.push(defaultApi); + } + + const functionAppResourceId = `https://management.azure.com${functionApp.id}`; + + // Configure a default backend link if not explicity defined + const defaultBackend: BackendContract = { + credentials: { + header: { + "x-functions-key": [`{{${this.serviceName}-key}}`], + }, + }, + name: `${this.serviceName}-backend`, + title: functionApp.name, + description: "Function App Backend", + protocol: "http", + resourceId: functionAppResourceId, + url: "api" + }; + + if (this.apimConfig.backends.length === 0) { + this.apimConfig.backends.push(defaultBackend); + } + } } diff --git a/src/services/azureKeyVaultService.ts b/src/services/azureKeyVaultService.ts index 069ed197..8bb0c5ad 100644 --- a/src/services/azureKeyVaultService.ts +++ b/src/services/azureKeyVaultService.ts @@ -18,8 +18,6 @@ export interface AzureKeyVaultConfig { * Services for the Key Vault Plugin */ export class AzureKeyVaultService extends BaseService { - private funcApp: FunctionAppService; - /** * Initialize key vault service and get function app * @param serverless Serverless object @@ -27,7 +25,6 @@ export class AzureKeyVaultService extends BaseService { */ public constructor(serverless: Serverless, options: Serverless.Options) { super(serverless, options); - this.funcApp = new FunctionAppService(serverless, options); } /** @@ -36,13 +33,15 @@ export class AzureKeyVaultService extends BaseService { */ public async setPolicy(keyVaultConfig: AzureKeyVaultConfig) { const subscriptionID = this.subscriptionId; + const functionAppService = new FunctionAppService(this.serverless, this.options); + const keyVaultClient = new KeyVaultManagementClient(this.credentials, subscriptionID); - const func = await this.funcApp.get(); - const identity = func.identity; + const functionApp = await functionAppService.get(); + const identity = functionApp.identity; let vault: Vault; - const keyVaultClient = new KeyVaultManagementClient(this.credentials, subscriptionID); + try { - vault = await keyVaultClient.vaults.get(keyVaultConfig.resourceGroup, keyVaultConfig.name) + vault = await keyVaultClient.vaults.get(keyVaultConfig.resourceGroup, keyVaultConfig.name); } catch (error) { throw new Error("Error: Specified vault not found") } @@ -56,6 +55,6 @@ export class AzureKeyVaultService extends BaseService { } vault.properties.accessPolicies.push(newEntry); - return keyVaultClient.vaults.createOrUpdate(keyVaultConfig.resourceGroup, keyVaultConfig.name, {location: vault.location, properties: vault.properties}) + await keyVaultClient.vaults.createOrUpdate(keyVaultConfig.resourceGroup, keyVaultConfig.name, {location: vault.location, properties: vault.properties}); } } diff --git a/src/services/baseService.ts b/src/services/baseService.ts index e1a513ab..2969e8e8 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -49,9 +49,7 @@ export abstract class BaseService { this.storageAccountName = StorageAccountResource.getResourceName(this.config); if (!this.credentials && authenticate) { - throw new Error( - `Azure Credentials has not been set in ${this.constructor.name}` - ); + throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); } } @@ -196,7 +194,7 @@ export abstract class BaseService { * Log message to Serverless CLI * @param message Message to log */ - protected log(message: string, options?: ServerlessLogOptions, entity?: string,) { + protected log(message: string, options?: ServerlessLogOptions, entity?: string) { (this.serverless.cli.log as any)(message, entity, options); } diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index 0045018f..6942f8be 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -154,23 +154,26 @@ export class FunctionAppService extends BaseService { this.log("Deploying serverless functions..."); const functionZipFile = this.getFunctionZipFile(); - const blobUpload = this.uploadZippedArtifactToBlobStorage(functionZipFile); if (this.deploymentConfig.runFromBlobUrl) { this.log("Updating function app setting to run from external package..."); - await blobUpload; + await this.uploadZippedArtifactToBlobStorage(functionZipFile); + const sasUrl = await this.blobService.generateBlobSasTokenUrl( this.deploymentConfig.container, this.artifactName - ) + ); + await this.updateFunctionAppSetting( functionApp, configConstants.runFromPackageSetting, sasUrl - ) + ); } else { - const functionAppUpload = this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile); - Promise.all([blobUpload, functionAppUpload]); + await Promise.all([ + this.uploadZippedArtifactToBlobStorage(functionZipFile), + this.uploadZippedArfifactToFunctionApp(functionApp, functionZipFile) + ]); } this.log("Deployed serverless functions:") @@ -184,7 +187,7 @@ export class FunctionAppService extends BaseService { if (httpConfig) { const method = httpConfig.methods[0].toUpperCase(); - this.log(`-> ${functionConfig.name}: ${method} ${httpConfig.url}`); + this.log(`-> ${functionConfig.name}: [${method}] ${httpConfig.url}`); } } }); @@ -256,7 +259,7 @@ export class FunctionAppService extends BaseService { /** * Uploads artifact file to blob storage container */ - private async uploadZippedArtifactToBlobStorage(functionZipFile: string) { + private async uploadZippedArtifactToBlobStorage(functionZipFile: string): Promise { await this.blobService.initialize(); await this.blobService.createContainerIfNotExists(this.deploymentConfig.container); await this.blobService.uploadFile( diff --git a/src/services/loginService.ts b/src/services/loginService.ts index 8ed84dc4..d2ad10e6 100644 --- a/src/services/loginService.ts +++ b/src/services/loginService.ts @@ -26,7 +26,7 @@ export class AzureLoginService extends BaseService { * set or via interactive login if environment variables are not set * @param options Options for different authentication methods */ - public async login(options?: AzureTokenCredentialsOptions|InteractiveLoginOptions): Promise { + public async login(options?: AzureTokenCredentialsOptions | InteractiveLoginOptions): Promise { const subscriptionId = process.env.azureSubId; const clientId = process.env.azureServicePrincipalClientId; const secret = process.env.azureServicePrincipalPassword; @@ -40,11 +40,11 @@ export class AzureLoginService extends BaseService { } public async interactiveLogin(options?: InteractiveLoginOptions): Promise { - let authResp: AuthResponse = {credentials: undefined, subscriptions: []}; + let authResp: AuthResponse = { credentials: undefined, subscriptions: [] }; const fileTokenCache = new SimpleFileTokenCache(); - if(fileTokenCache.isEmpty()){ + if (fileTokenCache.isEmpty()) { await open("https://microsoft.com/devicelogin"); - authResp = await interactiveLoginWithAuthResponse({...options, tokenCache: fileTokenCache}); + authResp = await interactiveLoginWithAuthResponse({ ...options, tokenCache: fileTokenCache }); fileTokenCache.addSubs(authResp.subscriptions); } else { authResp.credentials = new DeviceTokenCredentials(undefined, undefined, fileTokenCache.first().userId, undefined, undefined, fileTokenCache); @@ -52,10 +52,9 @@ export class AzureLoginService extends BaseService { } return authResp; - } public async servicePrincipalLogin(clientId: string, secret: string, tenantId: string, options: AzureTokenCredentialsOptions): Promise { - return loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId, options); + return await loginWithServicePrincipalSecretWithAuthResponse(clientId, secret, tenantId, options); } } diff --git a/src/services/packageService.ts b/src/services/packageService.ts index 4e340cca..8ab94776 100644 --- a/src/services/packageService.ts +++ b/src/services/packageService.ts @@ -24,14 +24,14 @@ export class PackageService extends BaseService { /** * Creates the function.json binding files required for the serverless service */ - public createBindings(): Promise { + public async createBindings(): Promise { const createEventsPromises = this.serverless.service.getAllFunctions() .map((functionName) => { const metaData = Utils.getFunctionMetaData(functionName, this.serverless); return this.createBinding(functionName, metaData); }); - return Promise.all(createEventsPromises); + await Promise.all(createEventsPromises); } /** @@ -95,10 +95,12 @@ export class PackageService extends BaseService { functionJSON.scriptFile = functionMetadata.handlerPath; const functionObject = this.slsFunctions()[functionName]; const bindingAzureSettings = Utils.getIncomingBindingConfig(functionObject)["x-azure-settings"]; + if (bindingAzureSettings.route) { // Find incoming binding within functionJSON and set the route const index = (functionJSON.bindings as any[]) .findIndex((binding) => (!binding.direction || binding.direction === "in")); + functionJSON.bindings[index].route = bindingAzureSettings.route; } @@ -108,7 +110,6 @@ export class PackageService extends BaseService { } const functionJsonString = JSON.stringify(functionJSON, null, 2); - fs.writeFileSync(path.join(functionDirPath, "function.json"), functionJsonString); return Promise.resolve(); diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index c02d3278..ad81fb92 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -1,4 +1,4 @@ -import { ApiContract, ApiManagementServiceResource } from "@azure/arm-apimanagement/esm/models"; +import { ApiContract, ApiManagementServiceResource, BackendContract } from "@azure/arm-apimanagement/esm/models"; import { FunctionEnvelope, Site } from "@azure/arm-appservice/esm/models"; import { DeploymentExtended, DeploymentsListByResourceGroupResponse } from "@azure/arm-resources/esm/models"; import { HttpHeaders, HttpOperationResponse, HttpResponse, WebResource } from "@azure/ms-rest-js"; @@ -36,7 +36,7 @@ export class MockFactory { sls.config.servicePath = ""; sls.setProvider = jest.fn(); sls["processedInput"] = { - commands: [ ServerlessCliCommand.DEPLOY ], + commands: [ServerlessCliCommand.DEPLOY], options: {} }; return sls; @@ -184,13 +184,14 @@ export class MockFactory { } public static createTestParameters(wrap = true) { - return (wrap) ? { - param1: { value: "1", type: "String" }, - param2: { value: "2", type: "String" }, - } : { - param1: "1", - param2: "2", - } + return (wrap) + ? { + param1: { value: "1", type: "String" }, + param2: { value: "2", type: "String" }, + } : { + param1: "1", + param2: "2", + } } public static createTestDeployment(name?: string, second: number = 0): DeploymentExtended { @@ -298,14 +299,15 @@ export class MockFactory { public static createTestApimConfig(generateName: boolean = false): ApiManagementConfig { return { name: generateName ? null : "test-apim-resource", - api: { + apis: [{ name: "test-apim-api1", subscriptionRequired: false, displayName: "API 1", description: "description of api 1", protocols: ["https"], path: "test-api1", - }, + }], + backends: [], }; } @@ -521,10 +523,42 @@ export class MockFactory { }; } - public static createTestApimApi(): ApiContract { + public static createTestApimApis(count: number): ApiContract[] { + const apis: ApiContract[] = []; + for (let i = 1; i <= count; i++) { + const api = MockFactory.createTestApimApi(i); + apis.push(api); + } + + return apis; + } + + public static createTestApimApi(index: number = 1): ApiContract { + return { + displayName: `API ${index}`, + description: `Description for API ${index}`, + name: `Api${index}`, + path: `/api${index}`, + }; + } + + public static createTestApimBackends(count: number): BackendContract[] { + const backends: BackendContract[] = []; + for (let i = 1; i <= count; i++) { + const backend = MockFactory.createTestApimBackend(i); + backends.push(backend); + } + + return backends; + } + + public static createTestApimBackend(index: number = 1): BackendContract { return { - name: "Api1", - path: "/api1", + name: `backend-${index}`, + description: `Description for Backend ${index}`, + title: `Backend ${index}`, + url: `/backend${index}`, + protocol: "http", }; }