diff --git a/.travis.yml b/.travis.yml index ba9b137a..5d82365c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,53 @@ +# since we don't need sudo, travis recommend xenial +dist: xenial + language: node_js -os: linux +# we don't need previous commits, so don't need default depth of 50 +git: + depth: 1 -matrix: - include: - - node_js: '8' - - node_js: '10' +# have to enable branch build to trigger on git tag but +# we don't want the same job to run twice on pull request +branches: + only: + - master + - dev + - /^v.*$/ # Ensure to build release tags of format v1.0.0-1 -sudo: false +stages: + - test + - name: publish + if: tag IS present # https://docs.travis-ci.com/user/conditions-v1 install: -- npm install -script: - - npm run test:ci + - npm install -after_success: -- cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage +jobs: + include: + - stage: test + name: "Unit Tests on Node 8" + node_js: "8" + script: + - npm run test:ci + after_success: + - npm run test:coverage + - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage + - name: "Unit Tests on Node 10" + node_js: "10" -deploy: - provider: npm - email: $NPM_EMAIL_ACCOUNT - api_key: - secure: Iu5Q2uuiF+jRWkNRtX77YElMwyEopzKD+iHQQkT7HheH12AwDAW/PZiwLRhBTif8kvHmIIwPhjey6i7QwmJFUmpb433299WF+tinRhy5mBFntdaNmgS2Wvv/hOFJza2XdOeIsKA+s+zPAGArfLonmvQX5QKVmJI7/W+hyqJT0FjtTMqxPyJ00ENuDeZdUEcsaoDAwu1XTA6bj5ILnguR+smeGoZscpp8nwTv27fA+GWLFGf08pPAyuHSwc1/lRxSbLlw/f0ZqnCclGU+SF2EV4/9+xxcPWqtJdJfURR/LaOkg3Q5o4aILRjNJNCbpRkrc5lEcPEvBPLrI6e7F9ssg08aHniaMvdD5B9TsgGqV6sxZX/KvMIHWfzBRLuCzY2bPE8Wbw+ZI/yi9+89NZdRikAMyWocvg4tx1OsQRcrRltsXqQrZyw45IR5oSv0kLx54xrslXu8jjP08jQEpnBncJp883DX6DmO42TRP4QYHsQ8BB5hHQ2qmVoKMydkLy0Kdvq7e+9v/pZHEMsR/HOwz1S5xYsSeqwqoq4u/H+hPFWu3GVJXbsVLHZuaDU3fBRMtJOcjujPszrvN/cNt2JryTT1EYPJotmpURqu/n9vPLMzoi1wIKPrZiAcEzh8MLlM93MyG/yuPeHZOvMuwCVtOXd3KKH8sEJvbTVUtKIvQXw= - on: - tags: true - repo: serverless/serverless-azure-functions - branch: master + - stage: publish + name: "NPM release for dev" # rename before merge to master + node_js: "8" + before_deploy: + - npm run build + deploy: + - provider: npm + email: "sls-az@microsoft.com" + api_key: + secure: hFgX3Ru3/CnT90d5WYXZ6Yr91zsHw+D+ZxaDdimtjMSjhZjKgA6HJ82A9SlpVRIm0WcwVYyBX8nd2j4d+Gkcefd8B4tt/UHOxuzFmt9Q5GvyjdKgA0jzY4XqjUogh142IUJUABRodmh8Wr7mHWCXhNNJu17UUyEf+KRliz72Orp36JEmozdgf+CpzVyzGUL2OLBZSmkjpFTUS4clOVhzwj0G2IHNB7qcupAVQ+r3DOjiU1N4+KkT+yrWH1esqOZYWVcqyVTUgkrfLU1eyaP8IemS+nT3Y9l0VxXY6nnZ4fkENbrivaN5cgp/GPSFb6QSiMbwImaPgxhyj2sIb1ydnoza5lR83b5Tsaa5E90gUbU23kms9J0Qi+6WLaI63kdfvQidl2A5UVpDr+lVusg0gfibkMUo4Jbof67o9coAu+WO80qS0jdn9cAg3gX6Xx9c7fUdHrFfxw4TBxjfI0SO7gJwbZvLZsZ9WsTT/SflIUQL72MhiYTQNIk7ewIVQXjb6eDuZS0tgSu3Gb0W7CIsVW7fwMmMvlEfzkzH119YYVUwwBCACfCgyXaFaamJ1tLHRlp1yjDjkBMZLlbOh18cSrTBu86+RBSX+I1jRvNoUxSTtzZjCI9i7sv9lEP2zq5EEMbA8qjCguu48knqQIrzsPNVCysKrNI964aVb8k4MyY= + skip_cleanup: true + tag: beta # tag all npm publish from dev branch with "beta" TODO: remove before merge to master + on: # https://docs.travis-ci.com/user/deployment/#conditional-releases-with-on + tags: true + repo: serverless/serverless-azure-functions diff --git a/README.md b/README.md index 0cfb1515..f545fbb2 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,33 @@ This will remove the `{functionName}` directory and remove the function from `se *Note: Add & remove currently only support HTTP triggered functions. For other triggers, you will need to update `serverless.yml` manually +### Running Function App Locally (`offline` plugin) + +In order to run a Azure Function App locally, the `azure-functions-core-tools` package needs to be installed from NPM. Since it is only used for local development, we did not include it in the `devDependencies` of `package.json`. To install globally, run: + +```bash +npm i azure-functions-core-tools -g +``` + +Then, at the root of your project directory, run: + +```bash +# Builds necessary function bindings files +sls offline +# Starts the function app +npm start +``` + +The build process will generate a directory for each of your functions, which will contain a file titled `function.json`. This will contain a relative reference to your handler file & exported function from that file as long as they are referenced correctly in `serverless.yml`. + +The `npm start` script just runs `func host start`, but we included the `npm` script for ease of use. + +To clean up files generated from the build, you can simply run: + +```bash +sls offline cleanup +``` + ### Deploy, test, and diagnose your Azure service 1. Deploy your new service to Azure! The first time you do this, you will be asked to authenticate with your Azure account, so the `serverless` CLI can manage Functions on your behalf. Simply follow the provided instructions, and the deployment will continue as soon as the authentication process is completed. diff --git a/package-lock.json b/package-lock.json index 520820bb..b5ab78e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "serverless-azure-functions", - "version": "0.7.0", + "version": "1.0.0-3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1558,6 +1558,15 @@ "@types/node": "*" } }, + "@types/xml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/xml/-/xml-1.0.3.tgz", + "integrity": "sha512-qeqQIjDfSLjmWR0noFQmcPKCtqn0L68MchoEi1Zj33unPfC83Op3j2mBH2g4hAgOaWUobv/O86w7LObo6p4sDQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "12.0.12", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz", @@ -1744,7 +1753,6 @@ "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", @@ -1917,7 +1925,6 @@ "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", @@ -2387,8 +2394,7 @@ "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", - "optional": true + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" }, "body-parser": { "version": "1.19.0", @@ -2493,8 +2499,7 @@ "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", - "optional": true + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, "browser-process-hrtime": { "version": "0.1.3", @@ -2523,7 +2528,6 @@ "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", @@ -2560,7 +2564,6 @@ "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" @@ -2657,8 +2660,7 @@ "buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "optional": true + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=" }, "builtin-modules": { "version": "1.1.1", @@ -2840,7 +2842,6 @@ "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" @@ -3218,7 +3219,6 @@ "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", @@ -3231,7 +3231,6 @@ "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", @@ -3690,7 +3689,6 @@ "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", @@ -3752,7 +3750,6 @@ "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" } @@ -4132,7 +4129,6 @@ "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" @@ -4148,7 +4144,6 @@ "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" @@ -4678,8 +4673,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -5044,8 +5038,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -5093,7 +5086,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5132,13 +5124,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.3", - "bundled": true, - "optional": true + "bundled": true } } }, @@ -5446,7 +5436,6 @@ "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" @@ -5456,7 +5445,6 @@ "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" @@ -5466,7 +5454,6 @@ "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", @@ -5785,8 +5772,7 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "optional": true + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, "is-fullwidth-code-point": { "version": "1.0.0", @@ -7426,8 +7412,7 @@ "longest": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", - "optional": true + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" }, "loose-envify": { "version": "1.4.0", @@ -7518,7 +7503,6 @@ "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", @@ -7544,7 +7528,6 @@ "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" @@ -7635,14 +7618,12 @@ "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==", - "optional": true + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "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=", - "optional": true + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=" }, "minimatch": { "version": "3.0.4", @@ -8240,7 +8221,6 @@ "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", @@ -8349,7 +8329,6 @@ "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", @@ -8552,8 +8531,7 @@ "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", - "optional": true + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" }, "pseudomap": { "version": "1.0.2", @@ -8620,7 +8598,6 @@ "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" } @@ -9033,7 +9010,6 @@ "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" @@ -9423,7 +9399,6 @@ "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" @@ -9619,8 +9594,7 @@ "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==", - "optional": true + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" }, "source-map": { "version": "0.7.3", @@ -10177,8 +10151,7 @@ "tapable": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.9.tgz", - "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==", - "optional": true + "integrity": "sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==" }, "tar-stream": { "version": "1.6.2", @@ -11028,7 +11001,6 @@ "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" @@ -11037,8 +11009,7 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" } } }, @@ -11168,6 +11139,11 @@ "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", "dev": true }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=" + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index ef09617e..720da473 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-azure-functions", - "version": "0.7.0", + "version": "1.0.0-3", "description": "Provider plugin for the Serverless Framework v1.x which adds support for Azure Functions.", "license": "MIT", "main": "./lib/index.js", @@ -31,6 +31,9 @@ "internet of things", "serverless.com" ], + "files": [ + "lib/" + ], "dependencies": { "@azure/arm-apimanagement": "^5.1.0", "@azure/arm-appservice": "^5.7.0", @@ -42,7 +45,8 @@ "lodash": "^4.16.6", "open": "^6.3.0", "request": "^2.81.0", - "rimraf": "^2.6.3" + "rimraf": "^2.6.3", + "xml": "^1.0.1" }, "devDependencies": { "@babel/runtime": "^7.4.5", @@ -52,6 +56,7 @@ "@types/open": "^6.1.0", "@types/request": "^2.48.1", "@types/serverless": "^1.18.2", + "@types/xml": "^1.0.3", "@typescript-eslint/eslint-plugin": "^1.9.0", "@typescript-eslint/parser": "^1.9.0", "axios-mock-adapter": "^1.16.0", diff --git a/scripts/npmPublishBeta.sh b/scripts/npmPublishBeta.sh new file mode 100644 index 00000000..a3c30ec3 --- /dev/null +++ b/scripts/npmPublishBeta.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +set -e + +echo "This script will bump npm version and push changes to new branch" + +# echo "checkout new branch for version bump" +git checkout -b npmRelease + +## configure npm to sign commit +npm config set sign-git-tag true + +version=$(npm version prerelease -m "Bumped to version %s") + +echo "Bumped to version ${version}" + +# remove git tag, we don't want to tag pr +git tag -d ${version} + +# push to remote +git push origin npmRelease + +## only for testing CD on test branch +## after development, we push tag on dev/master manually +# git push origin ${version} diff --git a/src/index.ts b/src/index.ts index d4550730..42c35e96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { AzureLoginPlugin } from "./plugins/login/loginPlugin"; import { AzureApimServicePlugin } from "./plugins/apim/apimServicePlugin"; import { AzureApimFunctionPlugin } from "./plugins/apim/apimFunctionPlugin"; import { AzureFuncPlugin } from "./plugins/func/azureFunc"; +import { AzureOfflinePlugin } from "./plugins/offline/azureOfflinePlugin" export default class AzureIndex { @@ -32,6 +33,7 @@ export default class AzureIndex { this.serverless.pluginManager.addPlugin(AzureApimServicePlugin); this.serverless.pluginManager.addPlugin(AzureApimFunctionPlugin); this.serverless.pluginManager.addPlugin(AzureFuncPlugin); + this.serverless.pluginManager.addPlugin(AzureOfflinePlugin); } } diff --git a/src/models/apiManagement.ts b/src/models/apiManagement.ts index 0ba19750..8c8abeac 100644 --- a/src/models/apiManagement.ts +++ b/src/models/apiManagement.ts @@ -1,12 +1,41 @@ import { OperationContract, ApiContract, BackendContract } from "@azure/arm-apimanagement/esm/models"; +/** + * Defines the serverless APIM configuration + */ export interface ApiManagementConfig { + /** The name of the APIM azure resource */ name: string; + /** The API contract configuration */ api: ApiContract; + /** The API's backend contract configuration */ backend?: BackendContract; + /** The API's CORS policy */ + cors?: ApiCorsPolicy; } +/** + * 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 + */ +export interface ApiCorsPolicy { + /** Whether or not to allow credentials */ + allowCredentials: boolean; + /** A list of allowed domains - also supports wildcard "*" */ + allowedOrigins: string[]; + /** A list of allowed HTTP methods */ + allowedMethods: string[]; + /** A list of allowed headers */ + allowedHeaders: string[]; + /** A list of headers exposed during OPTION preflight requests */ + exposeHeaders: string[]; } \ No newline at end of file diff --git a/src/plugins/func/azureFunc.test.ts b/src/plugins/func/azureFunc.test.ts index dfe7ab96..9c490c1a 100644 --- a/src/plugins/func/azureFunc.test.ts +++ b/src/plugins/func/azureFunc.test.ts @@ -1,10 +1,9 @@ import fs from "fs"; import mockFs from "mock-fs"; -import path from "path"; +import rimraf from "rimraf"; import { MockFactory } from "../../test/mockFactory"; import { invokeHook } from "../../test/utils"; import { AzureFuncPlugin } from "./azureFunc"; -import rimraf from "rimraf"; describe("Azure Func Plugin", () => { @@ -34,6 +33,10 @@ describe("Azure Func Plugin", () => { mockFs.restore(); }); + afterEach(() => { + jest.clearAllMocks(); + }) + it("returns with missing name", async () => { const sls = MockFactory.createTestServerless(); const options = MockFactory.createTestServerlessOptions(); @@ -47,37 +50,31 @@ describe("Azure Func Plugin", () => { it("returns with pre-existing function", async () => { const sls = MockFactory.createTestServerless(); const options = MockFactory.createTestServerlessOptions(); - options["name"] = "myExistingFunction"; + options["name"] = "hello"; const plugin = new AzureFuncPlugin(sls, options); await invokeHook(plugin, "func:add:add"); - expect(sls.cli.log).toBeCalledWith("Function myExistingFunction already exists"); + expect(sls.cli.log).toBeCalledWith("Function hello already exists"); }); - it("creates function directory and updates serverless.yml", async () => { + it("creates function handler and updates serverless.yml", async () => { const sls = MockFactory.createTestServerless(); const options = MockFactory.createTestServerlessOptions(); const functionName = "myFunction"; options["name"] = functionName; - const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata(); + const expectedFunctionsYml = MockFactory.createTestSlsFunctionConfig(); expectedFunctionsYml[functionName] = MockFactory.createTestFunctionMetadata(functionName); const plugin = new AzureFuncPlugin(sls, options); - const mkdirSpy = jest.spyOn(fs, "mkdirSync"); await invokeHook(plugin, "func:add:add"); - expect(mkdirSpy).toBeCalledWith(functionName); - const writeFileCalls = (sls.utils.writeFileSync as any).mock.calls; - expect(writeFileCalls[0][0]).toBe(path.join(functionName, "index.js")); - expect(writeFileCalls[1][0]).toBe(path.join(functionName, "function.json")); + expect(writeFileCalls[0][0]).toBe(`./${functionName}.js`); - expect(writeFileCalls[2][0]).toBe("serverless.yml"); - expect(writeFileCalls[2][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml)); - - mkdirSpy.mockRestore(); + expect(writeFileCalls[1][0]).toBe("serverless.yml"); + expect(writeFileCalls[1][1]).toBe(MockFactory.createTestServerlessYml(true, expectedFunctionsYml)); }); }); @@ -85,8 +82,8 @@ describe("Azure Func Plugin", () => { beforeAll(() => { mockFs({ + "index.js": "contents", "function1": { - "index.js": "contents", "function.json": "contents", }, }); @@ -114,15 +111,25 @@ describe("Azure Func Plugin", () => { }); it("deletes directory and updates serverless.yml", async () => { + mockFs({ + "hello.js": "contents", + hello: { + "function.json": "contents", + } + }) const sls = MockFactory.createTestServerless(); const options = MockFactory.createTestServerlessOptions(); const plugin = new AzureFuncPlugin(sls, options); - const functionName = "function1"; + const functionName = "hello"; options["name"] = functionName; + const unlinkSpy = jest.spyOn(fs, "unlinkSync"); const rimrafSpy = jest.spyOn(rimraf, "sync"); await invokeHook(plugin, "func:remove:remove"); + expect(unlinkSpy).toBeCalledWith(`${functionName}.js`) expect(rimrafSpy).toBeCalledWith(functionName); - const expectedFunctionsYml = MockFactory.createTestFunctionsMetadata(); + unlinkSpy.mockRestore(); + rimrafSpy.mockRestore(); + const expectedFunctionsYml = MockFactory.createTestSlsFunctionConfig(); delete expectedFunctionsYml[functionName]; expect(sls.utils.writeFileSync).toBeCalledWith("serverless.yml", MockFactory.createTestServerlessYml(true, expectedFunctionsYml)) }); diff --git a/src/plugins/func/azureFunc.ts b/src/plugins/func/azureFunc.ts index 86c448fe..09a82014 100644 --- a/src/plugins/func/azureFunc.ts +++ b/src/plugins/func/azureFunc.ts @@ -1,12 +1,10 @@ -import fs from "fs"; -import path from "path"; -import rimraf from "rimraf"; import Serverless from "serverless"; -import { FuncPluginUtils } from "./funcUtils"; +import { FuncService } from "../../services/funcService"; export class AzureFuncPlugin { public hooks: { [eventName: string]: Promise }; public commands: any; + private service: FuncService; public constructor(private serverless: Serverless, private options: Serverless.Options) { @@ -50,6 +48,8 @@ export class AzureFuncPlugin { } } } + + this.service = new FuncService(serverless, options); } private async func() { @@ -57,57 +57,10 @@ export class AzureFuncPlugin { } private async add() { - if (!("name" in this.options)) { - this.serverless.cli.log("Need to provide a name of function to add"); - return; - } - const funcToAdd = this.options["name"] - const exists = fs.existsSync(funcToAdd); - if (exists) { - this.serverless.cli.log(`Function ${funcToAdd} already exists`); - return; - } - this.createFunctionDir(funcToAdd); - this.addToServerlessYml(funcToAdd); - } - - private createFunctionDir(name: string) { - this.serverless.cli.log("Creating function dir"); - try { - fs.mkdirSync(name); - } catch (e) { - this.serverless.cli.log(`Error making directory ${e}`); - } - this.serverless.utils.writeFileSync(path.join(name, "index.js"), FuncPluginUtils.getFunctionHandler(name)); - this.serverless.utils.writeFileSync(path.join(name, "function.json"), FuncPluginUtils.getFunctionJsonString(name, this.options)); - } - - private addToServerlessYml(name: string) { - this.serverless.cli.log("Adding to serverless.yml"); - const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless); - functionYml[name] = FuncPluginUtils.getFunctionSlsObject(name, this.options); - FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml); + this.service.add(); } private async remove() { - if (!("name" in this.options)) { - this.serverless.cli.log("Need to provide a name of function to remove"); - return; - } - const funcToRemove = this.options["name"]; - const exists = fs.existsSync(funcToRemove); - if (!exists) { - this.serverless.cli.log(`Function ${funcToRemove} does not exist`); - return; - } - this.serverless.cli.log(`Removing ${funcToRemove}`); - rimraf.sync(funcToRemove); - await this.removeFromServerlessYml(funcToRemove); - } - - private async removeFromServerlessYml(name: string) { - const functionYml = FuncPluginUtils.getFunctionsYml(this.serverless); - delete functionYml[name]; - FuncPluginUtils.updateFunctionsYml(this.serverless, functionYml) + this.service.remove(); } } \ No newline at end of file diff --git a/src/plugins/func/bindingTemplates/http.json b/src/plugins/func/bindingTemplates/http.json deleted file mode 100644 index 9f2d4d85..00000000 --- a/src/plugins/func/bindingTemplates/http.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [ - { - "authLevel": "anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "get", - "post" - ] - }, - { - "type": "http", - "direction": "out", - "name": "res" - } - ] -} \ No newline at end of file diff --git a/src/plugins/func/funcUtils.test.ts b/src/plugins/func/funcUtils.test.ts deleted file mode 100644 index 83480457..00000000 --- a/src/plugins/func/funcUtils.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MockFactory } from "../../test/mockFactory"; -import { FuncPluginUtils } from "./funcUtils"; - -describe("Func Utils", () => { - - it("gets functions yml", () => { - const sls = MockFactory.createTestServerless(); - const funcYaml = FuncPluginUtils.getFunctionsYml(sls); - expect(funcYaml).toEqual(MockFactory.createTestFunctionsMetadata()); - }); - - it("updates functions yml", () => { - const updatedFunctions = MockFactory.createTestFunctionsMetadata(3); - const originalSls = MockFactory.createTestServerlessYml(false, 2); - const sls = MockFactory.createTestServerless(); - FuncPluginUtils.updateFunctionsYml(sls, updatedFunctions, originalSls); - const calls = (sls.utils.writeFileSync as any).mock.calls[0] - expect(calls[0]).toBe("serverless.yml"); - const expected = MockFactory.createTestServerlessYml( - true, - MockFactory.createTestFunctionsMetadata(3) - ); - expect(calls[1]).toBe(expected); - }); - - it("adds new function name to function handler", () => { - const name = "This is my function name" - const handler = FuncPluginUtils.getFunctionHandler(name); - expect(handler) - .toContain(`body: "${name} " + (req.query.name || req.body.name)`); - }); -}); \ No newline at end of file diff --git a/src/plugins/func/funcUtils.ts b/src/plugins/func/funcUtils.ts deleted file mode 100644 index afc1449f..00000000 --- a/src/plugins/func/funcUtils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import yaml from "js-yaml"; -import Serverless from "serverless"; -import httpBinding from "./bindingTemplates/http.json" - -export class FuncPluginUtils { - - public static getServerlessYml(sls: Serverless) { - return sls.utils.readFileSync("serverless.yml"); - } - - public static getFunctionsYml(sls: Serverless, serverlessYml?: any) { - serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls); - return serverlessYml["functions"]; - } - - public static updateFunctionsYml(sls: Serverless, functionYml: any, serverlessYml?: any) { - serverlessYml = serverlessYml || FuncPluginUtils.getServerlessYml(sls); - serverlessYml["functions"] = functionYml; - sls.utils.writeFileSync("serverless.yml", yaml.dump(serverlessYml)); - } - - public static getFunctionHandler(name: string) { - return `"use strict"; - -module.exports.handler = async function (context, req) { - context.log("JavaScript HTTP trigger function processed a request."); - - if (req.query.name || (req.body && req.body.name)) { - context.res = { - // status: 200, /* Defaults to 200 */ - body: "${name} " + (req.query.name || req.body.name) - }; - } - else { - context.res = { - status: 400, - body: "Please pass a name on the query string or in the request body" - }; - } -};` - } - - public static getFunctionJsonString(name: string, options: any) { - // TODO: This is where we would just generate function JSON from SLS object - // using getFunctionSlsObject(name, options). Currently defaulting to http in and out - return JSON.stringify(httpBinding, null, 2); - } - - public static getFunctionSlsObject(name: string, options: any) { - return FuncPluginUtils.defaultFunctionSlsObject(name); - } - - private static defaultFunctionSlsObject(name: string) { - return { - handler: `src/handlers/${name}.handler`, - events: FuncPluginUtils.httpEvents() - } - } - - private static httpEvents() { - return [ - { - http: true, - "x-azure-settings": { - authLevel: "anonymous" - } - }, - { - http: true, - "x-azure-settings": { - direction: "out", - name: "res" - } - }, - ] - } -} \ No newline at end of file diff --git a/src/plugins/offline/azureOfflinePlugin.test.ts b/src/plugins/offline/azureOfflinePlugin.test.ts new file mode 100644 index 00000000..f786b78c --- /dev/null +++ b/src/plugins/offline/azureOfflinePlugin.test.ts @@ -0,0 +1,82 @@ +import fs from "fs"; +import mockFs from "mock-fs"; +import path from "path"; +import Serverless from "serverless"; +import { MockFactory } from "../../test/mockFactory"; +import { invokeHook } from "../../test/utils"; +import { AzureOfflinePlugin } from "./azureOfflinePlugin"; + +describe("Azure Offline Plugin", () => { + + function createPlugin(sls?: Serverless, options?: Serverless.Options) { + return new AzureOfflinePlugin( + sls || MockFactory.createTestServerless(), + options || MockFactory.createTestServerlessOptions(), + ) + } + + beforeEach(() => { + mockFs({}) + }); + + afterEach(() => { + mockFs.restore(); + }) + + it("invokes build hook", async () => { + const sls = MockFactory.createTestServerless(); + const plugin = createPlugin(sls); + const writeFileSpy = jest.spyOn(fs, "writeFileSync"); + await invokeHook(plugin, "offline:build:build"); + const calls = writeFileSpy.mock.calls; + const functionNames = sls.service.getAllFunctions(); + expect(calls).toHaveLength(functionNames.length + 1); + for (let i = 0; i < functionNames.length; i++) { + const name = functionNames[i]; + expect(calls[i][0]).toEqual(`${name}${path.sep}function.json`) + expect( + JSON.parse(calls[i][1]) + ).toEqual( + MockFactory.createTestBindingsObject(`..${path.sep}${name}.js`) + ); + } + expect(calls[calls.length - 1][0]).toEqual("local.settings.json"); + writeFileSpy.mockRestore(); + }); + + it("invokes offline hook", async () => { + const sls = MockFactory.createTestServerless(); + const plugin = createPlugin(sls); + await invokeHook(plugin, "offline:offline"); + // Trivial test for now. In the future, this process + // may spawn the start process itself rather than telling + // the user how to do it. + expect(sls.cli.log).toBeCalledTimes(3); + }); + + it("invokes cleanup hook", async () => { + mockFs({ + hello: { + "function.json": "contents" + }, + goodbye: { + "function.json": "contents" + }, + "local.settings.json": "contents", + }); + const unlinkSpy = jest.spyOn(fs, "unlinkSync"); + const rmdirSpy = jest.spyOn(fs, "rmdirSync") + const sls = MockFactory.createTestServerless(); + const plugin = createPlugin(sls); + await invokeHook(plugin, "offline:cleanup:cleanup"); + const unlinkCalls = unlinkSpy.mock.calls; + expect(unlinkCalls[0][0]).toBe(`hello${path.sep}function.json`); + expect(unlinkCalls[1][0]).toBe(`goodbye${path.sep}function.json`); + expect(unlinkCalls[2][0]).toBe("local.settings.json"); + const rmdirCalls = rmdirSpy.mock.calls; + expect(rmdirCalls[0][0]).toBe("hello"); + expect(rmdirCalls[1][0]).toBe("goodbye"); + unlinkSpy.mockRestore(); + rmdirSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/src/plugins/offline/azureOfflinePlugin.ts b/src/plugins/offline/azureOfflinePlugin.ts new file mode 100644 index 00000000..2a96789e --- /dev/null +++ b/src/plugins/offline/azureOfflinePlugin.ts @@ -0,0 +1,54 @@ +import Serverless from "serverless"; +import { OfflineService } from "../../services/offlineService"; + +export class AzureOfflinePlugin { + public hooks: { [eventName: string]: Promise }; + public commands: any; + private offlineService: OfflineService; + + public constructor(private serverless: Serverless, private options: Serverless.Options) { + this.offlineService = new OfflineService(this.serverless, this.options); + + this.hooks = { + "before:offline:offline": this.azureOfflineBuild.bind(this), + "offline:build:build": this.azureOfflineBuild.bind(this), + "offline:offline": this.azureOfflineStart.bind(this), + "offline:cleanup:cleanup": this.azureOfflineCleanup.bind(this), + }; + + this.commands = { + offline: { + usage: "Start Azure Function App offline", + lifecycleEvents: [ + "offline", + ], + commands: { + build: { + usage: "Build necessary files for running Azure Function App offline", + lifecycleEvents: [ + "build", + ] + }, + cleanup: { + usage: "Clean up files from offline development", + lifecycleEvents: [ + "cleanup" + ] + } + } + } + } + } + + private async azureOfflineBuild(){ + this.offlineService.build(); + } + + private async azureOfflineStart(){ + this.offlineService.start(); + } + + private async azureOfflineCleanup(){ + this.offlineService.cleanup(); + } +} \ No newline at end of file diff --git a/src/services/apimService.test.ts b/src/services/apimService.test.ts index 51671acb..896c32a6 100644 --- a/src/services/apimService.test.ts +++ b/src/services/apimService.test.ts @@ -5,7 +5,7 @@ import { ApiManagementConfig } from "../models/apiManagement"; import { ApimService } from "./apimService"; import { interpolateJson } from "../test/utils"; import axios from "axios"; -import { Api, Backend, Property, ApiOperation, ApiOperationPolicy, ApiManagementService } from "@azure/arm-apimanagement"; +import { Api, Backend, Property, ApiOperation, ApiOperationPolicy, ApiManagementService, ApiPolicy } from "@azure/arm-apimanagement"; import apimGetService404 from "../test/responses/apim-get-service-404.json"; import apimGetService200 from "../test/responses/apim-get-service-200.json"; import apimGetApi200 from "../test/responses/apim-get-api-200.json"; @@ -18,6 +18,7 @@ import { ApiOperationCreateOrUpdateResponse, ApiManagementServiceResource, ApiGetResponse, ApiManagementServiceGetResponse, OperationContract, + ApiPolicyCreateOrUpdateResponse, } from "@azure/arm-apimanagement/esm/models"; describe("APIM Service", () => { @@ -235,13 +236,13 @@ describe("APIM Service", () => { }); it("ensures API, backend and keys have all been set", async () => { - 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(() => Promise.resolve(null)); const apimService = new ApimService(serverless); const result = await apimService.deployApi(); @@ -254,6 +255,9 @@ describe("APIM Service", () => { expectedApi, ); + // No CORS policy by default + expect(ApiPolicy.prototype.createOrUpdate).not.toBeCalled(); + expect(Backend.prototype.createOrUpdate).toBeCalledWith( resourceGroupName, serviceName, @@ -269,6 +273,34 @@ describe("APIM Service", () => { ); }); + it("deploys API CORS policy when defined within configuration", async () => { + 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 corsPolicy = MockFactory.createTestMockApiCorsPolicy(); + serverless.service.provider["apim"]["cors"] = corsPolicy; + + const apimService = new ApimService(serverless); + const result = await apimService.deployApi(); + + expect(result).not.toBeNull(); + expect(ApiPolicy.prototype.createOrUpdate).toBeCalledWith( + resourceGroupName, + serviceName, + apiName, + { + format: "rawxml", + value: expect.stringContaining("cors"), + } + ); + }); + it("returns null when APIM is not configured", async () => { serverless.service.provider["apim"] = null; diff --git a/src/services/apimService.ts b/src/services/apimService.ts index 55e54e85..a6452f95 100644 --- a/src/services/apimService.ts +++ b/src/services/apimService.ts @@ -1,8 +1,9 @@ import Serverless from "serverless"; +import xml from "xml"; import { ApiManagementClient } from "@azure/arm-apimanagement"; import { FunctionAppService } from "./functionAppService"; import { BaseService } from "./baseService"; -import { ApiManagementConfig, ApiOperationOptions } from "../models/apiManagement"; +import { ApiManagementConfig, ApiOperationOptions, ApiCorsPolicy } from "../models/apiManagement"; import { ApiContract, BackendContract, OperationContract, PropertyContract, ApiManagementServiceResource, @@ -130,7 +131,7 @@ export class ApimService extends BaseService { this.log("-> Deploying API"); try { - return await this.apimClient.api.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, { + const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, { isCurrent: true, subscriptionRequired: this.config.api.subscriptionRequired, displayName: this.config.api.displayName, @@ -138,8 +139,20 @@ export class ApimService extends BaseService { path: this.config.api.path, protocols: this.config.api.protocols, }); + + if (this.config.cors) { + this.log("-> Deploying CORS policy"); + + await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, { + format: "rawxml", + value: this.createCorsXmlPolicy(this.config.cors) + }); + } + + return api; } catch (e) { this.log("Error creating APIM API"); + this.log(JSON.stringify(e.body, null, 4)); throw e; } } @@ -171,6 +184,7 @@ export class ApimService extends BaseService { }); } catch (e) { this.log("Error creating APIM Backend"); + this.log(JSON.stringify(e.body, null, 4)); throw e; } } @@ -210,28 +224,14 @@ export class ApimService extends BaseService { await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, options.function, { format: "rawxml", - value: ` - - - - - - - - - - - - - - - `, + value: this.createApiOperationXmlPolicy(), }); return operation; } catch (e) { this.log(`Error deploying API operation ${options.function}`); this.log(JSON.stringify(e.body, null, 4)); + throw e; } } @@ -239,7 +239,7 @@ export class ApimService extends BaseService { * Gets the master key for the function app and stores a reference in the APIM instance * @param functionAppUrl The host name for the Azure function app */ - private async ensureFunctionAppKeys(functionApp): Promise { + private async ensureFunctionAppKeys(functionApp: Site): Promise { this.log("-> Deploying API keys"); try { const masterKey = await this.functionAppService.getMasterKey(functionApp); @@ -252,7 +252,73 @@ export class ApimService extends BaseService { }); } catch (e) { this.log("Error creating APIM Property"); + this.log(JSON.stringify(e.body, null, 4)); throw e; } } + + /** + * Creates the XML payload that defines the API operation policy to link to the configured backend + */ + private createApiOperationXmlPolicy(): string { + const operationPolicy = [{ + policies: [ + { + inbound: [ + { base: null }, + { + "set-backend-service": [ + { + "_attr": { + "id": "apim-generated-policy", + "backend-id": this.serviceName, + } + }, + ], + }, + ], + }, + { backend: [{ base: null }] }, + { outbound: [{ base: null }] }, + { "on-error": [{ base: null }] }, + ] + }]; + + return xml(operationPolicy); + } + + /** + * Creates the XML payload that defines the specified CORS policy + * @param corsPolicy The CORS policy + */ + private createCorsXmlPolicy(corsPolicy: ApiCorsPolicy): string { + const origins = corsPolicy.allowedOrigins ? corsPolicy.allowedOrigins.map((origin) => ({ origin })) : null; + const methods = corsPolicy.allowedMethods ? corsPolicy.allowedMethods.map((method) => ({ method })) : null; + const allowedHeaders = corsPolicy.allowedHeaders ? corsPolicy.allowedHeaders.map((header) => ({ header })) : null; + const exposeHeaders = corsPolicy.exposeHeaders ? corsPolicy.exposeHeaders.map((header) => ({ header })) : null; + + const policy = [{ + policies: [ + { + inbound: [ + { base: null }, + { + cors: [ + { "_attr": { "allow-credentials": corsPolicy.allowCredentials } }, + { "allowed-origins": origins }, + { "allowed-methods": methods }, + { "allowed-headers": allowedHeaders }, + { "expose-headers": exposeHeaders }, + ] + } + ], + }, + { backend: [{ base: null }] }, + { outbound: [{ base: null }] }, + { "on-error": [{ base: null }] }, + ] + }]; + + return xml(policy, { indent: "\t" }); + } } \ No newline at end of file diff --git a/src/services/baseService.ts b/src/services/baseService.ts index cbaa9b94..d95bd169 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -1,7 +1,7 @@ -import Serverless from "serverless"; import axios from "axios"; -import request from "request"; import fs from "fs"; +import request from "request"; +import Serverless from "serverless"; import { Guard } from "../shared/guard"; export abstract class BaseService { @@ -12,7 +12,7 @@ export abstract class BaseService { protected resourceGroup: string; protected deploymentName: string; - protected constructor(protected serverless: Serverless, protected options?: Serverless.Options) { + protected constructor(protected serverless: Serverless, protected options?: Serverless.Options, authenticate = true) { Guard.null(serverless); this.baseUrl = "https://management.azure.com"; @@ -22,14 +22,10 @@ export abstract class BaseService { this.resourceGroup = serverless.service.provider["resourceGroup"] || `${this.serviceName}-rg`; this.deploymentName = serverless.service.provider["deploymentName"] || `${this.resourceGroup}-deployment`; - if (!this.credentials) { + if (!this.credentials && authenticate) { throw new Error(`Azure Credentials has not been set in ${this.constructor.name}`); } } - - protected log(message: string) { - this.serverless.cli.log(message); - } /** * Sends an API request using axios HTTP library @@ -73,4 +69,16 @@ export abstract class BaseService { })); }); } + + protected log(message: string) { + this.serverless.cli.log(message); + } + + protected slsFunctions() { + return this.serverless.service["functions"]; + } + + protected slsConfigFile(): string { + return ("config" in this.options) ? this.options["config"] : "serverless.yml"; + } } diff --git a/src/services/funcService.ts b/src/services/funcService.ts new file mode 100644 index 00000000..b84cda7a --- /dev/null +++ b/src/services/funcService.ts @@ -0,0 +1,128 @@ +import yaml from "js-yaml"; +import rimraf from "rimraf"; +import Serverless from "serverless"; +import { BaseService } from "./baseService"; +import fs from "fs"; + +export class FuncService extends BaseService { + public constructor(serverless: Serverless, options: Serverless.Options){ + super(serverless, options, false); + } + + public add() { + const functionName = this.options["name"]; + if (!functionName) { + this.log("Need to provide a name of function to add"); + return; + } + if (this.exists(functionName)) { + this.serverless.cli.log(`Function ${functionName} already exists`); + return; + } + this.createHandler(functionName); + this.addToServerlessYml(functionName); + } + + public remove() { + const functionName = this.options["name"]; + if (!functionName) { + this.log("Need to provide a name of function to remove"); + return; + } + if (!this.exists(functionName)) { + this.log(`Function ${functionName} does not exist`); + return; + } + const fileName = `${functionName}.js`; + if (fs.existsSync(fileName)) { + fs.unlinkSync(fileName); + } + if (fs.existsSync(functionName)) { + rimraf.sync(functionName); + } + this.removeFromServerlessYml(functionName); + } + + private exists(functionName: string) { + return (functionName in this.slsFunctions()); + } + + private createHandler(functionName: string) { + this.serverless.utils.writeFileSync(`./${functionName}.js`, this.getFunctionHandler(functionName)) + } + + private addToServerlessYml(functionName: string) { + const functions = this.slsFunctions(); + functions[functionName] = this.getFunctionSlsObject(functionName) + this.updateFunctionsYml(functions) + } + + private removeFromServerlessYml(functionName: string) { + const functions = this.slsFunctions(); + delete functions[functionName]; + this.updateFunctionsYml(functions) + } + + private getServerlessYml() { + return this.serverless.utils.readFileSync(this.slsConfigFile()); + } + + private updateFunctionsYml(functionYml: any) { + const serverlessYml = this.getServerlessYml(); + serverlessYml["functions"] = functionYml; + this.serverless.utils.writeFileSync( + this.slsConfigFile(), + yaml.dump(serverlessYml) + ); + } + + private getFunctionHandler(name: string) { + return `"use strict"; + +module.exports.handler = async function (context, req) { + context.log("JavaScript HTTP trigger function processed a request."); + + if (req.query.name || (req.body && req.body.name)) { + context.res = { + // status: 200, /* Defaults to 200 */ + body: "${name} " + (req.query.name || req.body.name) + }; + } + else { + context.res = { + status: 400, + body: "Please pass a name on the query string or in the request body" + }; + } +};` + } + + private getFunctionSlsObject(name: string) { + return this.defaultFunctionSlsObject(name); + } + + private defaultFunctionSlsObject(name: string) { + return { + handler: `${name}.handler`, + events: this.httpEvents() + } + } + + private httpEvents() { + return [ + { + http: true, + "x-azure-settings": { + authLevel: "anonymous" + } + }, + { + http: true, + "x-azure-settings": { + direction: "out", + name: "res" + } + }, + ] + } +} \ No newline at end of file diff --git a/src/services/offlineService.test.ts b/src/services/offlineService.test.ts new file mode 100644 index 00000000..c4168007 --- /dev/null +++ b/src/services/offlineService.test.ts @@ -0,0 +1,82 @@ +import fs from "fs"; +import mockFs from "mock-fs"; +import path from "path"; +import Serverless from "serverless"; +import { MockFactory } from "../test/mockFactory"; +import { OfflineService } from "./offlineService"; + +describe("Offline Service", () => { + + function createService(sls?: Serverless): OfflineService { + return new OfflineService( + sls || MockFactory.createTestServerless(), + MockFactory.createTestServerlessOptions(), + ) + } + + beforeEach(() => { + // Mocking the file system so that files are not created in project directory + mockFs({}) + }); + + afterEach(() => { + mockFs.restore(); + }) + + it("builds required files for offline execution", async () => { + const sls = MockFactory.createTestServerless(); + const service = createService(sls); + const writeFileSpy = jest.spyOn(fs, "writeFileSync"); + await service.build(); + const calls = writeFileSpy.mock.calls; + const functionNames = sls.service.getAllFunctions(); + expect(calls).toHaveLength(functionNames.length + 1); + for (let i = 0; i < functionNames.length; i++) { + const name = functionNames[i]; + expect(calls[i][0]).toEqual(`${name}${path.sep}function.json`) + expect( + JSON.parse(calls[i][1]) + ).toEqual( + MockFactory.createTestBindingsObject(`..${path.sep}${name}.js`) + ); + } + expect(calls[calls.length - 1][0]).toEqual("local.settings.json"); + writeFileSpy.mockRestore(); + }); + + it("cleans up functions files", async () => { + mockFs({ + hello: { + "function.json": "contents" + }, + goodbye: { + "function.json": "contents" + }, + "local.settings.json": "contents", + }) + const sls = MockFactory.createTestServerless(); + const service = createService(sls); + const unlinkSpy = jest.spyOn(fs, "unlinkSync"); + const rmdirSpy = jest.spyOn(fs, "rmdirSync") + await service.cleanup(); + const unlinkCalls = unlinkSpy.mock.calls; + expect(unlinkCalls[0][0]).toBe(`hello${path.sep}function.json`); + expect(unlinkCalls[1][0]).toBe(`goodbye${path.sep}function.json`); + expect(unlinkCalls[2][0]).toBe("local.settings.json"); + const rmdirCalls = rmdirSpy.mock.calls; + expect(rmdirCalls[0][0]).toBe("hello"); + expect(rmdirCalls[1][0]).toBe("goodbye"); + unlinkSpy.mockRestore(); + rmdirSpy.mockRestore(); + }); + + it("instructs users how to run locally", async () => { + const sls = MockFactory.createTestServerless(); + const service = createService(sls); + await service.start(); + // Trivial test for now. In the future, this process + // may spawn the start process itself rather than telling + // the user how to do it. + expect(sls.cli.log).toBeCalledTimes(3); + }); +}); \ No newline at end of file diff --git a/src/services/offlineService.ts b/src/services/offlineService.ts new file mode 100644 index 00000000..5661c83d --- /dev/null +++ b/src/services/offlineService.ts @@ -0,0 +1,58 @@ +import Serverless from "serverless"; +import fs from "fs"; +import { BaseService } from "./baseService"; +import { PackageService } from "./packageService"; + +export class OfflineService extends BaseService { + + private packageService: PackageService; + + private localFiles = { + "local.settings.json": JSON.stringify({ + IsEncrypted: false, + Values: { + AzureWebJobsStorage: "", + FUNCTIONS_WORKER_RUNTIME: "node" + } + }), + } + + public constructor(serverless: Serverless, options: Serverless.Options) { + super(serverless, options, false); + this.packageService = new PackageService(serverless); + } + + public async build() { + this.log("Building offline service"); + await this.packageService.createBindings(); + const filenames = Object.keys(this.localFiles); + for (const filename of filenames) { + if (!fs.existsSync(filename)){ + fs.writeFileSync( + filename, + this.localFiles[filename] + ) + } + } + this.log("Finished building offline service"); + } + + public async cleanup() { + this.log("Cleaning up offline files") + await this.packageService.cleanUp(); + const filenames = Object.keys(this.localFiles); + for (const filename of filenames) { + if (fs.existsSync(filename)){ + this.log(`Removing file '${filename}'`); + fs.unlinkSync(filename) + } + } + this.log("Finished cleaning up offline files"); + } + + public start() { + this.log("Run 'npm start' or 'func host start' to run service locally"); + this.log("Make sure you have Azure Functions Core Tools installed"); + this.log("If not installed run 'npm i azure-functions-core-tools -g") + } +} \ No newline at end of file diff --git a/src/shared/binding.test.ts b/src/shared/binding.test.ts index c78e19b5..870385d8 100644 --- a/src/shared/binding.test.ts +++ b/src/shared/binding.test.ts @@ -13,6 +13,7 @@ describe("Bindings", () => { beforeEach(() => { sls = MockFactory.createTestServerless(); sls.config.servicePath = process.cwd(); + jest.clearAllMocks(); }); it("should get bindings metadata from serverless", () => { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 979bb9a7..4ed98f73 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -20,5 +20,5 @@ export const constants = { queue: "queue", queueName: "queueName", xAzureSettings: "x-azure-settings", - entryPoint: "entryPoint" + entryPoint: "entryPoint", } \ No newline at end of file diff --git a/src/shared/utils.test.ts b/src/shared/utils.test.ts index d577d764..3f37d702 100644 --- a/src/shared/utils.test.ts +++ b/src/shared/utils.test.ts @@ -1,7 +1,7 @@ -import Serverless from "serverless"; import path from "path"; +import Serverless from "serverless"; import { MockFactory } from "../test/mockFactory"; -import { Utils, FunctionMetadata } from "./utils"; +import { FunctionMetadata, Utils } from "./utils"; describe("utils", () => { let sls: Serverless; @@ -19,6 +19,7 @@ describe("utils", () => { it("resolves handler when handler code is outside function folders", () => { sls.service["functions"].hello.handler = "src/handlers/hello.handler"; + MockFactory.updateService(sls); const functions = sls.service.getAllFunctions(); const metadata = Utils.getFunctionMetaData(functions[0], sls); @@ -34,6 +35,7 @@ describe("utils", () => { it("resolves handler when code is in function folder", () => { sls.service["functions"].hello.handler = "hello/index.handler"; + MockFactory.updateService(sls); const functions = sls.service.getAllFunctions(); const metadata = Utils.getFunctionMetaData(functions[0], sls); @@ -49,6 +51,7 @@ describe("utils", () => { it("resolves handler when code is at the project root", () => { sls.service["functions"].hello.handler = "hello.handler"; + MockFactory.updateService(sls); const functions = sls.service.getAllFunctions(); const metadata = Utils.getFunctionMetaData(functions[0], sls); diff --git a/src/shared/utils.ts b/src/shared/utils.ts index a2b944fa..9b8654a1 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -83,19 +83,19 @@ export class Utils { functionsJson.bindings = bindings; params.functionsJson = functionsJson; - const entryPointAndHandlerPath = Utils.getEntryPointAndHandlerPath(handler); + let { handlerPath, entryPoint } = Utils.getEntryPointAndHandlerPath(handler); if (functionObject["scriptFile"]) { - entryPointAndHandlerPath.handlerPath = functionObject["scriptFile"]; + handlerPath = functionObject["scriptFile"]; } return { - entryPoint: entryPointAndHandlerPath.entryPoint, - handlerPath: relative(functionName, entryPointAndHandlerPath.handlerPath), + entryPoint, + handlerPath: relative(functionName, handlerPath), params: params }; } - public static getEntryPointAndHandlerPath(handler) { + public static getEntryPointAndHandlerPath(handler: string) { let handlerPath = "handler.js"; let entryPoint = handler; const handlerSplit = handler.split("."); @@ -104,6 +104,7 @@ export class Utils { entryPoint = handlerSplit[handlerSplit.length - 1]; handlerPath = `${handler.substring(0, handler.lastIndexOf("."))}.js`; } + const metaData = { entryPoint: entryPoint, handlerPath: handlerPath diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 5810a3b6..7efaaa32 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -12,6 +12,7 @@ import PluginManager from "serverless/lib/classes/PluginManager"; import { ServerlessAzureConfig } from "../models/serverless"; import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider" import { Logger } from "../models/generic"; +import { ApiCorsPolicy } from "../models/apiManagement"; function getAttribute(object: any, prop: string, defaultValue: any): any { if (object && object[prop]) { @@ -28,10 +29,39 @@ export class MockFactory { sls.pluginManager = getAttribute(config, "pluginManager", MockFactory.createTestPluginManager()); sls.variables = getAttribute(config, "variables", MockFactory.createTestVariables()); sls.service = getAttribute(config, "service", MockFactory.createTestService()); - sls.service.getFunction = jest.fn((functionName) => sls.service["functions"][functionName]); + sls.config.servicePath = ""; return sls; } + public static createTestService(functions?): Service { + if (!functions) { + functions = MockFactory.createTestSlsFunctionConfig() + } + const serviceName = "serviceName"; + return { + getAllFunctions: jest.fn(() => Object.keys(functions)), + getFunction: jest.fn((name: string) => functions[name]), + getAllEventsInFunction: jest.fn(), + getAllFunctionsNames: jest.fn(() => Object.keys(functions)), + getEventInFunction: jest.fn(), + getServiceName: jest.fn(() => serviceName), + load: jest.fn(), + mergeResourceArrays: jest.fn(), + setFunctionNames: jest.fn(), + update: jest.fn(), + validate: jest.fn(), + custom: null, + provider: MockFactory.createTestAzureServiceProvider(), + service: serviceName, + artifact: "app.zip", + functions + } as any as Service; + } + + public static updateService(sls: Serverless) { + sls.service = MockFactory.createTestService(sls.service["functions"]); + } + public static createTestServerlessOptions(): Serverless.Options { return { extraServicePath: null, @@ -70,6 +100,25 @@ export class MockFactory { }; } + public static createTestFunctions(functionCount = 3) { + const functions = [] + for (let i = 0; i < functionCount; i++) { + functions.push(MockFactory.createTestFunction(`function${i + 1}`)); + } + return functions; + } + + public static createTestFunction(name: string = "TestFunction") { + return { + properties: { + name, + config: { + bindings: MockFactory.createTestBindings() + } + } + } + } + public static createTestAzureCredentials(): TokenClientCredentials { const credentials = { getToken: jest.fn(() => { @@ -140,19 +189,10 @@ export class MockFactory { plugins: [ "serverless-azure-functions" ], - functions: functionMetadata || MockFactory.createTestFunctionsMetadata(2), + functions: functionMetadata || MockFactory.createTestSlsFunctionConfig(), } return (asYaml) ? yaml.dump(data) : data; - } - - public static createTestFunctionsMetadata(functionCount = 2) { - const data = {} - for (let i = 0; i < functionCount; i++) { - const functionName = `function${i + 1}`; - data[functionName] = MockFactory.createTestFunctionMetadata(functionName); - } - return data; - } + } public static createTestFunctionApimConfig(name: string) { return { @@ -170,55 +210,34 @@ export class MockFactory { public static createTestFunctionMetadata(name: string) { return { - handler: `src/handlers/${name}.handler`, - events: [ - { - http: true, - "x-azure-settings": { - authLevel: "anonymous" - } - }, - { - http: true, - "x-azure-settings": { - direction: "out", - name: "res" - }, - } - ] - }; + "handler": `${name}.handler`, + "events": MockFactory.createTestFunctionEvents(), + } } - public static createTestService(functions?): Service { - if (!functions) { - functions = MockFactory.createTestSlsFunctionConfig() - } - const serviceName = "serviceName"; - return { - getAllFunctions: jest.fn(() => Object.keys(functions)), - getFunction: jest.fn(), - getAllEventsInFunction: jest.fn(), - getAllFunctionsNames: jest.fn(() => Object.keys(functions)), - getEventInFunction: jest.fn(), - getServiceName: jest.fn(() => serviceName), - load: jest.fn(), - mergeResourceArrays: jest.fn(), - setFunctionNames: jest.fn(), - update: jest.fn(), - validate: jest.fn(), - custom: null, - provider: MockFactory.createTestAzureServiceProvider(), - service: serviceName, - artifact: "app.zip", - functions - } as any as Service; + public static createTestFunctionEvents() { + return [ + { + "http": true, + "x-azure-settings": { + "authLevel": "anonymous" + } + }, + { + "http": true, + "x-azure-settings": { + "direction": "out", + "name": "res" + } + } + ] } public static createTestFunctionsResponse(functions?) { const result = [] functions = functions || MockFactory.createTestSlsFunctionConfig(); for (const name of Object.keys(functions)) { - result.push({ properties: MockFactory.createTestFunctionEnvelope(name)}); + result.push({ properties: MockFactory.createTestFunctionEnvelope(name) }); } return result; } @@ -253,7 +272,7 @@ export class MockFactory { subscriptionId: "azureSubId", } } - + public static createTestSite(name: string = "Test"): Site { return { id: "appId", @@ -288,14 +307,31 @@ export class MockFactory { return MockFactory.createTestHttpBinding(); } - public static createTestHttpBinding() { + public static createTestHttpBinding(direction: string = "in") { + if (direction === "in") { + return { + authLevel: "anonymous", + type: "httpTrigger", + direction, + name: "req", + } + } else { + return { + type: "http", + direction, + name: "res" + } + } + } + + public static createTestBindingsObject(name: string = "index.js") { return { - type: "httpTrigger", - authLevel: "anonymous", - direction: "in", - methods: [ - "get", - "post" + scriptFile: name, + entryPoint: "handler", + disabled: false, + bindings: [ + MockFactory.createTestHttpBinding("in"), + MockFactory.createTestHttpBinding("out") ] } } @@ -375,6 +411,16 @@ export class MockFactory { }); } + public static createTestMockApiCorsPolicy(): ApiCorsPolicy { + return { + allowCredentials: false, + allowedOrigins: ["*"], + allowedHeaders: ["*"], + exposeHeaders: ["*"], + allowedMethods: ["GET","POST"], + }; + } + private static createTestCli(): Logger { return { log: jest.fn(), diff --git a/src/test/responses/apim-put-api-policy-200.json b/src/test/responses/apim-put-api-policy-200.json new file mode 100644 index 00000000..ca813695 --- /dev/null +++ b/src/test/responses/apim-put-api-policy-200.json @@ -0,0 +1,9 @@ +{ + "id": "/subscriptions/d36d0808-a967-4f73-9fdc-32ea232fc81d/resourceGroups/${resourceGroup.name}/providers/Microsoft.ApiManagement/service/${service.name}/apis/${api.name}/policies/policy", + "type": "Microsoft.ApiManagement/service/apis/policies", + "name": "policy", + "properties": { + "format": "xml", + "value": "\t\t\t\t\t\t\t\t\t\t\t\t*\t\t\t\t\t\t\t\t\t\tGET\t\t\t\tPOST\t\t\t\tPUT\t\t\t\tDELETE\t\t\t\tPATCH\t\t\t\t\t\t\t\t\t\t
*
\t\t\t
\t\t\t\t\t\t\t
*
\t\t\t
\t\t
\t
\t\t\t\t\t\t\t\t\t\t\t\t
" + } +} \ No newline at end of file